commit 6682668c165a4818f3c02b58a5895e60aa244f64 Author: Emil Date: Sat Sep 12 23:22:20 2026 +0300 Initial minecraft-builder-mcp prototype and Gothic hall build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0c8abcd --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +**/target/ +**/build/ +**/.gradle/ +**/node_modules/ +bridge/dist/ +.runtime/ +**/.state/ +.tools/ +.env +.env.* +!.env.example +*.log +*.tmp +*.hprof +.idea/ +.DS_Store +graphify-out/ +__pycache__/ +*.py[cod] diff --git a/README.md b/README.md new file mode 100644 index 0000000..788145b --- /dev/null +++ b/README.md @@ -0,0 +1,95 @@ +# minecraft-builder-mcp + +Строительный редактор Minecraft для совместной работы человека и ИИ-агента. + +Рабочий прототип: Paper-плагин, ядро редактирования, MCP/ACP Bridge и Fabric-мод камеры. Строительство, конфликты, undo, аварийное восстановление и `.schem` проверены на локальном Paper через HTTP и настоящий MCP stdio. Камера проверена в Prism с одним клиентом: настоящий PNG 1280×720 передан через MCP. Вход в отдельный профиль Codex и первый ход модели через ACP ещё предстоит проверить. + +## Что умеет + +- Читает ограниченные участки и строит коробки, линии, цилиндры и повторяющиеся элементы. +- Сначала сохраняет план, затем применяет его порциями с проверкой текущих блоков. Ручное изменение останавливает конфликтующую запись; отмена тоже проверяет состояние мира. +- Хранит журнал на диске, различает повтор запроса и новую операцию, останавливает неоднозначные операции после сбоя. +- Сохраняет именованные части и их защиту, экспортирует и импортирует ограниченный Sponge v2 `.schem` через тот же механизм планов. +- Даёт 14 MCP-инструментов и `/ai` для игрового чата через закреплённый `codex-acp`. +- Снимает настоящие изображения через локальный Worker на spectator-клиенте. Проверен также один клиент с временным переключением владельца в spectator. + +Сейчас это один владелец, один проект и один мир, до 4096 блоков на план, только загруженные чанки и ограниченный набор ванильных материалов. Полный журнал дельт, автоматическое объединение ручных правок и все возможности дизайн-документа ещё не реализованы. [Подробный статус и ограничения](docs/IMPLEMENTATION.md). + +## Сборка + +Проверенная среда — Linux x64, Minecraft/Paper 26.2, Java 25, Node.js 22.22.3+. Из корня репозитория: + +```bash +./scripts/build.sh +``` + +Скрипт загружает закреплённые JDK 25.0.2 и Maven 3.9.11 в пользовательский кэш с проверкой хешей, устанавливает зависимости Bridge по lockfile и собирает все три компонента с тестами. Системная Java не меняется. Нужны уже установленные Python 3, Node.js и npm. [Закреплённые версии](docs/compatibility.json). + +Результаты: + +- `paper-plugin/target/paper-plugin-0.1.0-SNAPSHOT.jar` — серверный плагин, ядро включено. +- `camera-mod/build/libs/minecraft-builder-camera-0.1.0-SNAPSHOT.jar` — клиентский мод. +- `bridge/dist/` — исполняемые MCP/ACP-компоненты. + +## Локальный запуск + +1. Подготовить отдельный тестовый Paper. На первой установке прочитать [Minecraft EULA](https://www.minecraft.net/eula), затем принять её явно: + + ```bash + python3 scripts/dev-server.py --accept-eula --run + ``` + + Для последующих запусков достаточно `python3 scripts/dev-server.py --run`. Сервер хранится в `.runtime/server`, слушает `127.0.0.1:25575`, вход в Minecraft остаётся включён. Для уже подготовленного в этой рабочей папке сервера EULA принята пользователем. + +2. Подключиться клиентом Minecraft Java 26.2 к `127.0.0.1:25575`. В консоли **этого** сервера выдать своему игровому имени `op <имя>`. В игре выполнить: + + ```text + /ai setup + /ai area here + ``` + + Вторая команда выбирает участок вокруг игрока. Чанки должны быть загружены, а рядом с местами записи — поддерживаемые блоки. Точные границы можно задать через `/ai area minX minY minZ maxX maxY maxZ`. + +3. В другом терминале из корня проекта проверить настройки и войти в отдельный профиль Codex: + + ```bash + python3 scripts/bridge.py doctor + python3 scripts/bridge.py login + python3 scripts/bridge.py login --status + ``` + + Вход выполняется самим пользователем по device code. Помощник читает локальные токены Paper без вывода в терминал. Обычный профиль `~/.codex` не копируется; состояние проекта хранится в `.runtime/bridge-state`. Первый настоящий ход ещё должен подтвердить авторизацию и разрешения MCP у закреплённого адаптера. + +4. Запустить чат: + + ```bash + python3 scripts/bridge.py chat + ``` + + Теперь можно отправить `/ai Построй небольшую башню рядом со мной`. Для выбора модели доступна переменная `MCB_MODEL`; без неё выбор остаётся за адаптером. `/ai status` показывает состояние, `/ai stop` останавливает дальнейшую запись и запрос к агенту. Уже сделанные изменения отменяются отдельным проверяемым undo. + +MCP можно подключить к внешнему клиенту командой `python3 scripts/bridge.py mcp`. Область владельца берётся из конфигурации Paper. Команда предназначена для запуска клиентом MCP по stdio, а не для интерактивного терминала. + +## Камера + +Обычному игроку мод не нужен. Для наблюдателя установить Fabric Loader и API указанных версий, добавить JAR камеры в отдельный профиль 26.2. Перед запуском передать этому процессу `MCB_CAMERA_TOKEN` из поля `camera-token` приватной конфигурации плагина; в Paper заполнить `camera-player-uuid` и перезапустить сервер. Наблюдатель должен быть подключён к нему в spectator. + +Если строитель и наблюдатель играют одновременно, нужны допустимые отдельные игровые сессии. Для одного клиента можно указать UUID владельца и временно включать spectator. Такой сценарий уже проверен в профиле Prism **26.2 MCP Building**; секрет передаётся Java через `scripts/camera-wrapper.py`, без добавления в логируемые переменные Prism. Точный порядок и ограничения снимка — в [инструкции камеры](camera-mod/README.md). После подключения `/ai camera save name` сохраняет ракурс владельца. [Результат локального теста с одним клиентом](docs/ONE_CLIENT_TEST.md). + +## Проверки и документы + +```bash +./mvnw test +npm --prefix bridge test +JAVA_HOME="$HOME/.cache/minecraft-builder-mcp/jdk-25.0.2" camera-mod/gradlew --project-dir camera-mod test +``` + +`python3 scripts/live-server-test.py` запускает и останавливает собственный процесс в `.runtime/server`, проверяет конфликты и undo, намеренно завершает этот процесс для проверки восстановления, затем прогоняет MCP и `.schem`. Для него сначала собрать проект, один раз запустить плагин и принять EULA; текущий сервер должен быть остановлен. Проверка предназначена для подготовленного тестового мира и изменяет только ограниченные тестовые области. Результаты сохраняются в `.runtime/live-server-results.json` и `.runtime/live-mcp-results.log`. + +- [Дизайн проекта и дальнейшие этапы](docs/DESIGN.md). +- [Что реализовано и чем проверено](docs/IMPLEMENTATION.md). +- [Готический зал по референсу: 29 354 блока в живом мире](docs/builds/GOTHIC_HALL.md). +- [Протокол](docs/PROTOCOL.md), [ядро и журнал](world-core/README.md). +- [Bridge, вход и ограничения ACP](bridge/README.md). + +Git инициализирован, ветка `main`. Сгенерированные миры, секреты, зависимости и сборки исключены через `.gitignore`. diff --git a/bridge/.env.example b/bridge/.env.example new file mode 100644 index 0000000..c2363ea --- /dev/null +++ b/bridge/.env.example @@ -0,0 +1,19 @@ +MCB_BACKEND_URL=http://127.0.0.1:8765 +# Different tokens generated by the Paper plugin; replace locally, never commit. +MCB_TOKEN= +MCB_AGENT_TOKEN= +# Required only for externally launched stdio MCP. Chat sets these per session. +MCB_PLAYER_ID= +MCB_PROJECT_ID=default +# Optional ACP choices. Defaults: pinned adapter, configured model/login. +# MCB_MODEL= +# MCB_ACP_COMMAND= +# MCB_ACP_ARGS=[] +# MCB_ACP_AUTH_METHOD= +# MCB_STATE_DIR=.state/chat + +# Dedicated Codex auth/config home; defaults to .state/chat/codex-home. +# MCB_CODEX_HOME= +# Optional explicitly opted-in API auth, never inherited from generic OpenAI env vars. +# MCB_OPENAI_API_KEY= +# MCB_CODEX_API_KEY= diff --git a/bridge/README.md b/bridge/README.md new file mode 100644 index 0000000..62a74ed --- /dev/null +++ b/bridge/README.md @@ -0,0 +1,88 @@ +# Bridge + +Локальные MCP-инструменты и игровой ACP-клиент для `minecraft-builder-mcp`. + +Требуется Node.js 22.22.3+ и работающий Paper-плагин проекта. Установка воспроизводима по `package-lock.json`: + +```bash +npm ci --ignore-scripts +npm test +``` + +Прямые зависимости закреплены: `codex-acp` 1.11.0, ACP SDK 1.4.0, MCP SDK 1.30.0, Zod 4.6.2, TypeScript 7.0.2. Закреплённая транзитивная версия Codex — 0.153.4. `npm ci` не выбирает свежие версии. + +## Запуск + +Секреты берутся из окружения, а не из аргументов командной строки. Плагин создаёт отдельные административный и агентский токены. Не коммитьте их. Пример конфигурации переменных — `.env.example`; сам bridge не загружает `.env` автоматически. + +- `npm run doctor` проверяет `/health` Paper при наличии `MCB_TOKEN`, создаёт отдельную минимальную конфигурацию Codex и выводит JSON с настройками, ограничениями и точной командой входа. +- `npm run login` вручную запускает вход через device code в отдельный Codex home; `npm run login -- status` проверяет этот вход. +- `npm run mcp` запускает MCP по stdio для внешнего агента. Нужны `MCB_AGENT_TOKEN`, `MCB_PLAYER_ID`, `MCB_PROJECT_ID`. +- `npm run chat` запускает опрос `/ai` и ACP. Нужны `MCB_TOKEN` и `MCB_AGENT_TOKEN`. +- `node dist/rpc.js project_context` — прямой диагностический RPC с агентской областью доступа. + +Для локального сервера из этого репозитория удобнее запускать из корня `python3 scripts/bridge.py doctor`, `python3 scripts/bridge.py login`, `python3 scripts/bridge.py status` и затем `python3 scripts/bridge.py chat`. Обёртка использует общую папку `.runtime/bridge-state` и сама читает приватные токены Paper. `status` эквивалентен `login status` или `login --status`; эти команды только проверяют вход и не начинают авторизацию. `doctor`, `login` и `status` можно запускать ещё до создания конфигурации Paper. Не смешивайте вход через эту обёртку с обычным `npm run chat` без соответствующего `MCB_STATE_DIR`: у них разные папки состояния по умолчанию. + +`MCB_BACKEND_URL` по умолчанию `http://127.0.0.1:8765`. Разрешён только HTTP на loopback; для удалённого сервера нужен локальный SSH-туннель. UUID игрока/проекта поступают из доверенной конфигурации или ответа Paper, а сервер повторно проверяет владельца и область. В прототипе сервер поддерживает одного настроенного владельца. + +Игровой мост запускает установленный локальный `codex-acp`, без `npx @latest`. Он использует отдельный Codex home в `.state/chat/codex-home`; вход и настройки обычного `~/.codex` автоматически не копируются. Один раз запустите `npm run login` из той же рабочей директории и с тем же `MCB_STATE_DIR`, что и `chat`. Помощник использует закреплённый Codex CLI с `login --device-auth`; вход начинается только при явном запуске этой команды. Мост сам не открывает браузер и не обращается к модели во время сборки/обычных тестов. Для API-ключа задайте `MCB_OPENAI_API_KEY` или `MCB_CODEX_API_KEY` и `MCB_ACP_AUTH_METHOD=api-key`; общий `OPENAI_API_KEY` из родительского окружения не наследуется. + +Необязательные настройки: + +- `MCB_MODEL`: ID модели; применяется через объявленный ACP model selector. Без значения выбирает адаптер. +- `MCB_ACP_COMMAND`: путь к альтернативному ACP-агенту; без него используется текущий Node и закреплённый `codex-acp`. +- `MCB_ACP_ARGS`: JSON-массив аргументов, без shell-интерпретации. +- `MCB_STATE_DIR`: папка состояния, по умолчанию `.state/chat` относительно рабочей директории. +- `MCB_CODEX_HOME`: явный путь к отдельному Codex home для входа. Если там существует отличающийся `config.toml`, мост откажется запускаться и сохранит файл; используйте отдельную пустую папку, а не обычный профиль Codex. + +Дочерний процесс получает только разрешённые переменные окружения, отдельные HOME/XDG/CODEX_HOME и минимальный конфиг. Настройки запрашивают `read-only`, `on-request`, проверку пользователем, запрет сети внутри командного sandbox и отключение shell, приложений, браузера, computer use, hooks, plugins и дополнительных агентов. Источники и параметры приведены в `src/security.ts`. Проверка закреплённого CLI подтвердила отключённый `shell_tool` и перечисленные интеграции; `unified_exec` этот CLI оставляет включённым даже при явном отключении, что отражено в doctor. + +Есть ограничение самого `codex-acp` 1.11.0: его режим `read-only` посылает на каждый ход sandbox `workspace-write` с выключенной сетью, а не буквальный read-only. Поэтому папка конкретной сессии и временные пути могут оставаться доступными для записи. Код не заявляет полной изоляции ОС, и ещё не проверен на настоящем ходе модели. Процессы Bridge/ACP/MCP также остаются доверенными локальными программами; права Paper проверяются отдельно сервером. + +Bridge отклоняет дополнительные запросы разрешений с сообщением в игре. Интерактивное подтверждение разрешений через Minecraft пока не реализовано. ACP capabilities для файлов и терминала не объявляются. Административный токен Paper не передаётся дочернему агенту; MCP получает отдельный ограниченный агентский токен. Конфигурацию нужно проверять через doctor после смены версий или при наличии системных политик Codex. Для динамически передаваемого Minecraft MCP override `default_tools_approval_mode` не устанавливается: закреплённый адаптер передаёт только command/args/env и заменяет соответствующую таблицу конфигурации. Поэтому поведение разрешений MCP остаётся проверкой первого настоящего хода после входа пользователя; сборка и initialize не подтверждают, что строительство через модель уже работает. + +## MCP + +Инструменты: `project_context`, `region_inspect`, `build_prepare`, `build_apply`, `operation_status`, `operation_cancel`, `operation_undo_prepare`, `part_get`, `part_define`, `camera_list`, `camera_capture`, `asset_list`, `schematic_export`, `schematic_import_prepare`. + +Рецепт первой версии: + +```json +{ + "version": 1, + "operations": [ + {"type":"box","min":{"x":0,"y":64,"z":0},"max":{"x":4,"y":70,"z":4},"block":"minecraft:stone_bricks","hollow":true}, + {"type":"line","from":{"x":0,"y":64,"z":0},"to":{"x":8,"y":64,"z":8},"block":"minecraft:stone"}, + {"type":"cylinder","center":{"x":12,"y":64,"z":12},"radius":3,"height":8,"block":"minecraft:stone_bricks","hollow":true}, + {"type":"repeat","count":3,"offset":{"x":6,"y":0,"z":0},"operations":[{"type":"box","min":{"x":0,"y":64,"z":20},"max":{"x":1,"y":67,"z":21},"block":"minecraft:oak_log[axis=y]"}]} + ] +} +``` + +Необязательный `part_id` в `build_prepare` ограничивает запись точной маской зарегистрированной части; расширение задаётся отдельной частью. + +Инструменты отсылают данные на Paper для финальной проверки и вычисления. Мост не хранит блоки и не пишет мир. MCP принимает один уровень `repeat`; глубоко вложенные повторения, произвольный код, арки и общие трансформации пока не объявлены. Поддерживаемые состояния блоков и лимиты берутся из `project_context`. + +`build_prepare` возвращает `plan_id` и `plan_hash`. Для `build_apply` нужно передать их вместе со стабильным `idempotency_key`. При таймауте запись могла уже начаться: сначала проверьте `operation_status` и используйте тот же ключ. Мост не повторяет запись автоматически. + +Локальная библиотека `.schem` поддерживает до 64 файлов, Sponge v2, плотные области до 4096 блоков и повороты 0/90/180/270°. Файлы вручную помещаются в папку `schematics` внутри данных Paper-плагина. `asset_list` возвращает метаданные без выдуманных превью; `schematic_export` сохраняет регион и возвращает ID; `schematic_import_prepare` создаёт обычный проверяемый план, который затем применяется через `build_apply`. Пути, сущности, block entities и неподдерживаемые блоки не принимаются. + +`camera_capture` возвращает `pending` и `captureId`; запрос с `capture_id` читает результат. Только `completed` с настоящим изображением превращается в MCP `ImageContent`. Если камеры нет или снимок не готов, изображение не выдумывается. Обычный текстовый ответ ограничен 64 KiB; чтение слишком большой области завершается ошибкой с просьбой уменьшить область. HTTP ограничен по времени, входному объёму и размеру потокового ответа. + +## Диалоги и остановка + +Один активный ход на проект, до восьми сообщений в очереди. Разные проекты могут обрабатываться независимо. Сессии и папки разделены по проекту и UUID игрока. Ответы отправляются только инициатору через `chat_reply`; общий игровой чат, мысли модели, tool-аргументы и stderr адаптера не транслируются. + +Каждый запрос включает переданные сервером позицию игрока, направление взгляда и целевой блок, если они доступны. Вывод сообщений ограничен до четырёх сообщений в секунду для одного игрока. + +ID ACP-сессии и последняя компактная сводка сохраняются атомарной заменой `session.json` с правами `0600`. После перезапуска мост пробует `session/load`; при отказе начинает новый диалог со сводкой и явно сообщает об этом. Повтор истории при загрузке не выводится в чат. Незавершённый предыдущий ход помечается отдельно; уже начатые операции необходимо сверить на Paper. Сводка хранит последнее задание и итог, она не заменяет `project_context`. + +`/ai stop` должен одновременно установить серверный флаг остановки записи и доставить мосту событие `type:"cancel"`. ACP отменяет генерацию; уже изменённые блоки остаются в журнале. Прерванный агент, не отвечающий на cancel пять секунд, завершается. При каждом запуске мост создаёт `client_id` и передаёт его в `chat_poll`. Смена ID позволяет Paper остановить активные записи и завершить оставшиеся арендованные запросы с уведомлением пользователя; такие задания автоматически не повторяются. Очередь входящих сообщений Paper пока хранится в памяти. После `/ai stop` сервер удерживает запись на паузе до нового задания владельца или `/ai resume`. + +## Проверки + +`npm test` выполняет HTTP-тесты и настоящий stdio MCP handshake, а также использует отдельный mock ACP-процесс для проверки сессий, резюме, скрытия истории, отказа разрешений и отмены. Проверки очередей подтверждают сериализацию внутри проекта и независимость других проектов. Реальный закреплённый `codex-acp` также прошёл бесплатный `initialize`: ACP v1, `loadSession: true`, методы входа `api-key` и `chat-gpt` до ограничения окружения. Повторная проверка в отдельном home также успешна; при `NO_BROWSER=1` адаптер объявляет только `api-key`, а ChatGPT-вход выполняется отдельным helper `login`. Это не доказывает вход ChatGPT, качество модели или поведение Minecraft: для этого требуется запуск всей системы на настоящем сервере/клиенте. + +Опциональный `node test/live-paper.mjs` запускается только против отдельного настоящего тестового Paper: проверяет полый куб, повторное применение с тем же ключом, экспорт `.schem`, библиотеку ассетов, отмену, импорт в тот же anchor, вторую отмену до 27 блоков воздуха, границы и честный ответ недоступной камеры. Он оставляет экспортированный тестовый asset в локальной библиотеке. Это изменяющая мир проверка, она не входит в обычный `npm test`. + +Исходные API сверены с [ACP SDK](https://github.com/agentclientprotocol/typescript-sdk), [codex-acp](https://github.com/agentclientprotocol/codex-acp), [MCP SDK](https://github.com/modelcontextprotocol/typescript-sdk) [справочником конфигурации Codex](https://learn.chatgpt.com/docs/config-file/config-reference) и [документацией MCP Codex](https://learn.chatgpt.com/docs/extend/mcp?surface=cli). diff --git a/bridge/package-lock.json b/bridge/package-lock.json new file mode 100644 index 0000000..36ef7c2 --- /dev/null +++ b/bridge/package-lock.json @@ -0,0 +1,1957 @@ +{ + "name": "minecraft-builder-mcp", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "minecraft-builder-mcp", + "version": "0.1.0", + "dependencies": { + "@agentclientprotocol/codex-acp": "1.11.0", + "@agentclientprotocol/sdk": "1.4.0", + "@modelcontextprotocol/sdk": "1.30.0", + "zod": "4.6.2" + }, + "bin": { + "minecraft-builder-chat": "dist/chat.js", + "minecraft-builder-mcp": "dist/mcp.js" + }, + "devDependencies": { + "@types/node": "22.20.2", + "typescript": "7.0.2" + }, + "engines": { + "node": ">=22.22.3" + } + }, + "node_modules/@agentclientprotocol/codex-acp": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/codex-acp/-/codex-acp-1.11.0.tgz", + "integrity": "sha512-opPKsRaekgdmQpOpHrR0EEDn9chgtiN+b+h0V78fTuQP84TNzB7vrn3EtKODwbiJQTBHJAlynjSFQazFfaT+VQ==", + "license": "Apache-2.0", + "dependencies": { + "@agentclientprotocol/sdk": "^1.4.0", + "@openai/codex": "^0.153.4", + "diff": "^9.0.0", + "open": "^11.0.1", + "vscode-jsonrpc": "^9.0.1", + "zod": "^4.0.0" + }, + "bin": { + "codex-acp": "dist/index.js" + } + }, + "node_modules/@agentclientprotocol/sdk": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-1.4.0.tgz", + "integrity": "sha512-/eufudw+aFY1LKLolT6yFE6UMmYRl7fMJ/DEONSIyR6wI3slHWITBsANRGqXEY8FRzqUxwh7QEaGiZHcJPVThg==", + "license": "Apache-2.0", + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/@hono/node-server": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-2.1.1.tgz", + "integrity": "sha512-ELuehkj5VCBdgEw9zs+ivkKwyzzUCSQuE96YmiPvn1ECBoZCczbFXJLeEGMTYjphP6gydh4pHMqEYPVMYUVgQg==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "peerDependencies": { + "hono": "^4" + } + }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.30.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.30.0.tgz", + "integrity": "sha512-xKd8OIzlqNzcqcNumGAa6g+PW2kjD5vrpcKOnfldAUPP3j7lnqMPwlTXQm8gF+UwH72z0lqaRbjr9hqGz0eITA==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9 || ^2.0.5", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, + "node_modules/@openai/codex": { + "version": "0.153.4", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4.tgz", + "integrity": "sha512-wbHDmit7S/YvBGVX1DQmk13xtWblZ2cApeJ/pB7xDZ10Cna+DZc5ij7f0F4OxdsXN4FW1oLT48OpogUI1+8Y2w==", + "license": "Apache-2.0", + "bin": { + "codex": "bin/codex.js" + }, + "engines": { + "node": ">=16" + }, + "optionalDependencies": { + "@openai/codex-darwin-arm64": "npm:@openai/codex@0.153.4-darwin-arm64", + "@openai/codex-darwin-x64": "npm:@openai/codex@0.153.4-darwin-x64", + "@openai/codex-linux-arm64": "npm:@openai/codex@0.153.4-linux-arm64", + "@openai/codex-linux-x64": "npm:@openai/codex@0.153.4-linux-x64", + "@openai/codex-win32-arm64": "npm:@openai/codex@0.153.4-win32-arm64", + "@openai/codex-win32-x64": "npm:@openai/codex@0.153.4-win32-x64" + } + }, + "node_modules/@openai/codex-darwin-arm64": { + "name": "@openai/codex", + "version": "0.153.4-darwin-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-darwin-arm64.tgz", + "integrity": "sha512-B1qhN3fa1ay0R0wGziXqgwSkB5icpYChNKHhtBHff/0UtSTC7z+l8aTtvMlGjH3E8HEvY3+njIJelM9CAAoVWg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-darwin-x64": { + "name": "@openai/codex", + "version": "0.153.4-darwin-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-darwin-x64.tgz", + "integrity": "sha512-vnSbbPzfoDZmmyzsxswsDDXQ06IVFBzkQU7/hroB3ji93Ok2utcsq8Psfk2tjF5r9mEx8RWFJhzuTGHG26/NDA==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-arm64": { + "name": "@openai/codex", + "version": "0.153.4-linux-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-linux-arm64.tgz", + "integrity": "sha512-QKdjYLYV4hXIuUQDP3P6F4NXuWFoKo9WUoV4nAREIx55kiUyi8UsYdsVobkeXir5n/maEQgYMCKLHVma4rNPiw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-linux-x64": { + "name": "@openai/codex", + "version": "0.153.4-linux-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-linux-x64.tgz", + "integrity": "sha512-x1EcwBlY3AObM1VTUHNM2AzAJQsyreGdagpF+qFiYi/Oa30VBktvvG0C6tLtCzqW6hjZNWkGZQWmeVk7MuJKWg==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-arm64": { + "name": "@openai/codex", + "version": "0.153.4-win32-arm64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-win32-arm64.tgz", + "integrity": "sha512-/FBh42976ltF1kxDoPQBg1Q6+hwChRU5/sm5dfeC8kFVQMvOCGoGeY5d8rRZGVJE8XojlXo74VQb0sHowcfgBw==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@openai/codex-win32-x64": { + "name": "@openai/codex", + "version": "0.153.4-win32-x64", + "resolved": "https://registry.npmjs.org/@openai/codex/-/codex-0.153.4-win32-x64.tgz", + "integrity": "sha512-lMkB43kJZH0VFr+hoXc11qqR7QtQIbkr07ALgj4urKL1osNyUyuy1iXd3Vzz2iCYvBUCSw7I0l/W1cEPGx9euQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.2.tgz", + "integrity": "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@typescript/typescript-aix-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz", + "integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz", + "integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-darwin-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz", + "integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz", + "integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-freebsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz", + "integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz", + "integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz", + "integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-loong64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz", + "integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-mips64el": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz", + "integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-ppc64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz", + "integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-riscv64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz", + "integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-s390x": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz", + "integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-linux-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz", + "integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz", + "integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-netbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz", + "integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz", + "integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-openbsd-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz", + "integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-sunos-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz", + "integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-arm64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz", + "integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/@typescript/typescript-win32-x64": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz", + "integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/bundle-name": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", + "license": "MIT", + "dependencies": { + "run-applescript": "^7.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "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/default-browser": { + "version": "5.5.1", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.1.tgz", + "integrity": "sha512-m1pAzaJgZ/gssEqlOhJkPJp8Xly7QyW6xcrkUa2KKcDeDSEMP7X8xipU3snUcfisTQx0w1AGae+9UtJSfVnXGw==", + "license": "MIT", + "dependencies": { + "bundle-name": "^4.1.0", + "default-browser-id": "^5.0.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/default-browser-id": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/define-lazy-prop": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/diff": { + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/diff/-/diff-9.0.0.tgz", + "integrity": "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.1.tgz", + "integrity": "sha512-EKN1vKAMcZ8MlYMpaNuxN6R9yakzH6uajHcHVTqWJzvu5pWw9DyhbP35HH8MVBQ+dZjAfDxk+A8NiR9KWaXiyQ==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.7.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.7.0.tgz", + "integrity": "sha512-hOwV7WOxXfjRpAM1DSJWZDXx3GhplwD8IfwuwvogD8i1Qnkgosw/H45s4ZnFAUHDAhPjlY9hLBvJhKmGMyY26g==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "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-uri": { + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.7.tgz", + "integrity": "sha512-dOvZVzjdZdz7phd9v6jCbwxrBW3fK6n8Rc0CtdmM4bumzMnxywBYhuph6J819RRw/ku+rLbelwfMunktuzVVHg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.7", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.7.tgz", + "integrity": "sha512-c8/gF9ac8Y78/agExVocyLevgR+JlpNB444Py0FSX8pJoPdYUfUzRcXtYEYGwt6l19qIlVZPN5Mfsw9jFShmQQ==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.7.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.7.0.tgz", + "integrity": "sha512-BGFsyJd5mpXp3rK6jIdADLNgpJUK1jnjzvYF8lK+VyDab9JAmqN0YOKDdP17HlgKb2+ehPgDc8EtnRLbGCAMhA==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-docker": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-in-ssh": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-in-ssh/-/is-in-ssh-1.0.0.tgz", + "integrity": "sha512-jYa6Q9rH90kR1vKB6NM7qqd1mge3Fx4Dhw5TVlK1MUBqhEOuCagrEHMevNuCcbECmXZ0ThXkRm+Ymr51HwEPAw==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-inside-container": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", + "license": "MIT", + "dependencies": { + "is-docker": "^3.0.0" + }, + "bin": { + "is-inside-container": "cli.js" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/is-wsl": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.1.tgz", + "integrity": "sha512-e6rvdUCiQCAuumZslxRJWR/Doq4VpPR82kqclvcS0efgt430SlGIk05vdCN58+VrzgtIcfNODjozVielycD4Sw==", + "license": "MIT", + "dependencies": { + "is-inside-container": "^1.0.0" + }, + "engines": { + "node": ">=16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.12", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.12.tgz", + "integrity": "sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" + } + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "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/negotiator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.1.0.tgz", + "integrity": "sha512-NMPBRMJgiQHjbd8phG3Vebdx4kZ1H121rbl5IkMqeOsahptB9BKo/d7oJ3zTXqTgagn2bWlNSXkh0QUGM31RYg==", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/open": { + "version": "11.0.3", + "resolved": "https://registry.npmjs.org/open/-/open-11.0.3.tgz", + "integrity": "sha512-b3souUgU0VaAvMmzA4+9cRgrOgMdE0vc9H9VhQ0FYnjjcyZ8R/f9DiwMU9V8rl+vjf05On9UJhbgb06fsTs87Q==", + "license": "MIT", + "dependencies": { + "default-browser": "^5.5.1", + "define-lazy-prop": "^3.0.0", + "is-in-ssh": "^1.0.0", + "is-inside-container": "^1.0.0", + "powershell-utils": "^0.2.1", + "wsl-utils": "^1.0.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, + "node_modules/powershell-utils": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.2.1.tgz", + "integrity": "sha512-C+y9x90UElAddDZmV4qOx9W53B61PO7cIqWz2dQsWlwswuq4mr8NEwytdGKboYbQlGZ3awrkTeNvcZiZNHnQ8A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.16.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.16.0.tgz", + "integrity": "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/run-applescript": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.1.0.tgz", + "integrity": "sha512-mj7UPXE0jaqaOsukNZRUEfEi2AcL7C/vwmwcHV0O97eO1E1pxBZuyjlZrx5seTaNBg1U6+o35wpa35Qfcc+7ag==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typescript": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz", + "integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc" + }, + "engines": { + "node": ">=16.20.0" + }, + "optionalDependencies": { + "@typescript/typescript-aix-ppc64": "7.0.2", + "@typescript/typescript-darwin-arm64": "7.0.2", + "@typescript/typescript-darwin-x64": "7.0.2", + "@typescript/typescript-freebsd-arm64": "7.0.2", + "@typescript/typescript-freebsd-x64": "7.0.2", + "@typescript/typescript-linux-arm": "7.0.2", + "@typescript/typescript-linux-arm64": "7.0.2", + "@typescript/typescript-linux-loong64": "7.0.2", + "@typescript/typescript-linux-mips64el": "7.0.2", + "@typescript/typescript-linux-ppc64": "7.0.2", + "@typescript/typescript-linux-riscv64": "7.0.2", + "@typescript/typescript-linux-s390x": "7.0.2", + "@typescript/typescript-linux-x64": "7.0.2", + "@typescript/typescript-netbsd-arm64": "7.0.2", + "@typescript/typescript-netbsd-x64": "7.0.2", + "@typescript/typescript-openbsd-arm64": "7.0.2", + "@typescript/typescript-openbsd-x64": "7.0.2", + "@typescript/typescript-sunos-x64": "7.0.2", + "@typescript/typescript-win32-arm64": "7.0.2", + "@typescript/typescript-win32-x64": "7.0.2" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/vscode-jsonrpc": { + "version": "9.0.2", + "resolved": "https://registry.npmjs.org/vscode-jsonrpc/-/vscode-jsonrpc-9.0.2.tgz", + "integrity": "sha512-SbQSV9yRemARxeXw6LU5sS6Zq0e9/DgCCX5yelH263ZQWukbTk8EF8fjTrr1dziasf4GwlJbvTwFnTrnQFWZXQ==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, + "node_modules/wsl-utils": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-1.0.0.tgz", + "integrity": "sha512-Hl0ZOAs672vg+06kfujwRhoS6/jehvULrlFkuF2dRu6pHgA8U06h3xqNIqNNU1LTXPcedxByAR4GS6pwQK0mgA==", + "license": "MIT", + "dependencies": { + "is-wsl": "^3.1.0", + "powershell-utils": "^0.1.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/wsl-utils/node_modules/powershell-utils": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/powershell-utils/-/powershell-utils-0.1.0.tgz", + "integrity": "sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==", + "license": "MIT", + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.6.2.tgz", + "integrity": "sha512-lh5RCAGFa1Cm2hjtNwLQhSs/AsqdWnTQaBER9fEwN/88pSh7KOtJavtBx/0VlkN/uFd61SwYmljLMDAsHlvzBQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } + } + } +} diff --git a/bridge/package.json b/bridge/package.json new file mode 100644 index 0000000..4d0c187 --- /dev/null +++ b/bridge/package.json @@ -0,0 +1,33 @@ +{ + "name": "minecraft-builder-mcp", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Local MCP tools and ACP chat bridge for minecraft-builder-mcp", + "engines": { + "node": ">=22.22.3" + }, + "bin": { + "minecraft-builder-mcp": "dist/mcp.js", + "minecraft-builder-chat": "dist/chat.js" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "typecheck": "tsc -p tsconfig.json --noEmit", + "test": "npm run build && node --test test/*.test.mjs", + "mcp": "node dist/mcp.js", + "chat": "node dist/chat.js", + "doctor": "node dist/doctor.js", + "login": "node dist/login.js" + }, + "dependencies": { + "@agentclientprotocol/codex-acp": "1.11.0", + "@agentclientprotocol/sdk": "1.4.0", + "@modelcontextprotocol/sdk": "1.30.0", + "zod": "4.6.2" + }, + "devDependencies": { + "@types/node": "22.20.2", + "typescript": "7.0.2" + } +} diff --git a/bridge/src/acp.ts b/bridge/src/acp.ts new file mode 100644 index 0000000..c5d408f --- /dev/null +++ b/bridge/src/acp.ts @@ -0,0 +1,187 @@ +import { spawn, type ChildProcessWithoutNullStreams } from 'node:child_process'; +import { createHash } from 'node:crypto'; +import { readFile, mkdir, writeFile, rename } from 'node:fs/promises'; +import { createRequire } from 'node:module'; +import { join, resolve } from 'node:path'; +import { Readable, Writable } from 'node:stream'; +import { fileURLToPath } from 'node:url'; +import { client, ndJsonStream, PROTOCOL_VERSION, type ClientConnection, type McpServer, type SessionNotification } from '@agentclientprotocol/sdk'; + +import { agentEnvironment, codexPaths, prepareCodexHome } from './security.js'; +export { agentEnvironment } from './security.js'; + +const require = createRequire(import.meta.url); +export interface ChatMessage { id: string; playerId: string; projectId: string; text: string; type?: 'prompt' | 'cancel' | 'reset'; playerPosition?: { x: number; y: number; z: number; yaw?: number; pitch?: number }; worldId?: string; lookTarget?: { x: number; y: number; z: number } } +export type Reply = (text: string, done: boolean, error?: boolean) => Promise; +export interface AgentSession { prompt(text: string, reply: Reply): Promise; cancel(): Promise; close(): void } +export interface AcpOptions { + backendUrl: string; agentToken: string; stateDir: string; codexHome?: string; + command?: string; args?: string[]; model?: string; authMethod?: string; + timeoutMs?: number; startupTimeoutMs?: number; + env?: NodeJS.ProcessEnv; +} +interface SavedSession { sessionId: string; summary: string; interrupted: boolean } + +const INSTRUCTIONS = `You are the Minecraft builder for the current authorized project. Respond in the player's language using short game-chat messages. Use only minecraft-builder-mcp to inspect and edit the world. Begin each new task with project_context; read relevant world data before designing. Only implemented server capabilities may be used. Prepare compact geometry, inspect statistics, apply using stable idempotency keys, and poll operation_status. Preserve manual edits: conflict requires localized redesign or the user's decision, never blindly overwrite fresh snapshots. A cancelled or failed operation may have partial writes. Camera requests are asynchronous; poll capture_id and inspect actual image. If unavailable, explicitly say visually unverified. Never use console commands, shell, files, or other MCP servers to modify Minecraft. Do not claim any action completed without server evidence.`; + +export class CodexSession implements AgentSession { + private child?: ChildProcessWithoutNullStreams; + private connection?: ClientConnection; + private sessionId?: string; + private currentReply?: Reply; + private active = false; + private cancelled = false; + private buffer = ''; + private finalText = ''; + private lastSent = 0; + private sentChars = 0; + private firstPrompt = true; + private summary = ''; + private reportReset = false; + private reportInterrupted = false; + private readonly dir: string; + private readonly stateFile: string; + constructor(private readonly message: Pick, private readonly options: AcpOptions) { + const key = createHash('sha256').update(`${message.projectId}\0${message.playerId}`).digest('hex').slice(0,32); + this.dir = join(resolve(options.stateDir), key); this.stateFile = join(this.dir, 'session.json'); + } + private async start(): Promise { + if (this.connection && !this.connection.signal.aborted) return; + await mkdir(this.dir, { recursive: true, mode: 0o700 }); + const paths = codexPaths(this.options.stateDir, this.options.codexHome); + await prepareCodexHome(paths); + let saved: SavedSession | undefined; + try { + const raw = JSON.parse(await readFile(this.stateFile, 'utf8')) as SavedSession; + if (typeof raw.sessionId === 'string' && typeof raw.summary === 'string') saved = raw; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') this.reportReset = true; + } + this.summary = saved?.summary.slice(0, 5000) ?? ''; + this.reportInterrupted = saved?.interrupted ?? false; + const executable = this.options.command ?? process.execPath; + const args = this.options.args ?? (this.options.command ? [] : [require.resolve('@agentclientprotocol/codex-acp')]); + const child = spawn(executable, args, { cwd: this.dir, stdio: ['pipe','pipe','pipe'], env: agentEnvironment(this.options.env ?? process.env, paths), shell: false, detached: process.platform !== 'win32' }); + this.child = child; + // Adapter stderr may contain prompts or secrets. Drain it without logging raw content. + child.stderr.resume(); + const app = client({ name: 'minecraft-builder-mcp-chat' }); + app.onRequest('session/request_permission', async () => { + await this.currentReply?.('Codex запросил дополнительное разрешение. Оно отклонено: подтверждение через игровой чат пока не реализовано.', false, true); + return { outcome: { outcome: 'cancelled' } }; + }); + app.onNotification('session/update', ({ params }) => this.onUpdate(params)); + this.connection = app.connect(ndJsonStream(Writable.toWeb(child.stdin), Readable.toWeb(child.stdout) as unknown as ReadableStream)); + const connection = this.connection; + child.once('error', () => connection.close(new Error('Cannot start the ACP process. Check MCB_ACP_COMMAND.'))); + child.once('exit', () => connection.close(new Error('ACP process exited. Check Codex authentication and installation.'))); + const setupTimeout = setTimeout(() => connection.close(new Error('ACP startup timed out.')), this.options.startupTimeoutMs ?? 30_000); + try { + const init = await connection.agent.request('initialize', { protocolVersion: PROTOCOL_VERSION, clientInfo: { name:'minecraft-builder-mcp',version:'0.1.0' }, clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false } }); + if (init.protocolVersion !== PROTOCOL_VERSION) throw new Error('ACP protocol version is incompatible.'); + if (this.options.authMethod) { + if (!init.authMethods?.some(method => method.id === this.options.authMethod)) throw new Error('MCB_ACP_AUTH_METHOD was not advertised by the agent.'); + await connection.agent.request('authenticate', { methodId: this.options.authMethod }); + } + const mcpServers: McpServer[] = [{ name: 'minecraft-builder-mcp', command: process.execPath, + args: [fileURLToPath(new URL('./mcp.js', import.meta.url))], env: [ + { name: 'MCB_BACKEND_URL', value: this.options.backendUrl }, + { name: 'MCB_AGENT_TOKEN', value: this.options.agentToken }, + { name: 'MCB_PLAYER_ID', value: this.message.playerId }, + { name: 'MCB_PROJECT_ID', value: this.message.projectId }, + ] }]; + let configOptions; + if (saved && init.agentCapabilities?.loadSession) { + try { + const loaded = await connection.agent.request('session/load', { sessionId: saved.sessionId, cwd: this.dir, mcpServers }); + this.sessionId = saved.sessionId; configOptions = loaded.configOptions; this.firstPrompt = false; + } catch { this.reportReset = true; } + } + if (!this.sessionId) { + const created = await connection.agent.request('session/new', { cwd: this.dir, mcpServers }); + this.sessionId = created.sessionId; configOptions = created.configOptions; this.firstPrompt = true; + if (saved) this.reportReset = true; + } + if (this.options.model) { + const config = configOptions?.find(option => option.category === 'model' || option.id === 'model'); + if (!config) throw new Error('Agent did not advertise a model selector; unset MCB_MODEL or use a compatible adapter.'); + await connection.agent.request('session/set_config_option', { sessionId: this.sessionId, configId: config.id, value: this.options.model }); + } + await this.save(false); + } catch (error) { this.close(); throw error; } + finally { clearTimeout(setupTimeout); } + } + private async onUpdate(params: SessionNotification): Promise { + // History replay from session/load is suppressed; another player's chat never receives it. + if (!this.active || params.sessionId !== this.sessionId || !this.currentReply) return; + const update = params.update; + if (update.sessionUpdate === 'agent_message_chunk' && update.content.type === 'text') { + this.finalText = (this.finalText + update.content.text).slice(0, 8000); + this.buffer += update.content.text; + if (this.buffer.length >= 180 || Date.now() - this.lastSent >= 1500) await this.flush(false); + } + // Deliberately do not forward thought chunks, tool arguments, or raw terminal output. + } + private async flush(done: boolean): Promise { + const remaining = Math.max(0, 8000 - this.sentChars); + const clean = this.buffer.replace(/[\u0000-\u001f\u007f§]/g, ' ').trim().slice(0, remaining); + this.buffer = ''; + if (clean) { + for (let offset = 0; offset < clean.length; offset += 240) await this.currentReply?.(clean.slice(offset, offset + 240), false); + this.sentChars += clean.length; + } + this.lastSent = Date.now(); + if (done) await this.currentReply?.(this.cancelled ? 'Остановлено. Уже изменённые блоки остаются в истории.' : this.sentChars ? '' : 'Ход Codex завершён без текстового ответа; состояние мира доступно через /ai status.', true); + } + async prompt(text: string, reply: Reply): Promise { + if (this.active) throw new Error('This ACP session already has an active turn.'); + this.currentReply = reply; this.cancelled = false; + await this.start(); + if (this.cancelled) { await reply('Запрос остановлен до отправки Codex.', true); this.currentReply = undefined; return; } + if (this.reportReset) { await reply('Начат новый диалог Codex с сохранённой краткой сводкой проекта.', false); this.reportReset = false; } + if (this.reportInterrupted) { await reply('Предыдущий ход был прерван перезапуском. Проверю состояние операций перед новым строительством.', false); this.reportInterrupted = false; } + this.active = true; this.buffer = ''; this.finalText = ''; this.sentChars = 0; + const prefix = this.firstPrompt ? `${INSTRUCTIONS}\n${this.summary ? `Previous compact summary (historical, verify world): ${this.summary}\n` : ''}\nPlayer request:\n` : ''; + await this.save(true); + const timeout = setTimeout(() => { void this.cancel().catch(() => this.close()); }, this.options.timeoutMs ?? 15 * 60_000); + try { + const result = await this.connection!.agent.request('session/prompt', { sessionId: this.sessionId!, prompt: [{ type: 'text', text: `${prefix}${text}` }] }); + this.firstPrompt = false; + this.cancelled ||= result.stopReason === 'cancelled'; + this.summary = `Last player request: ${text.slice(0,2000)}\nLast agent response: ${this.finalText.slice(0,3000)}`; + await this.save(false); + await this.flush(true); + } finally { clearTimeout(timeout); this.active = false; this.currentReply = undefined; } + } + async cancel(): Promise { + this.cancelled = true; + if (this.sessionId && this.connection && !this.connection.signal.aborted) { + await this.connection.agent.notify('session/cancel', { sessionId: this.sessionId }); + // A stuck adapter must not keep a project queue blocked forever. + const target = this.connection; + setTimeout(() => { if (this.active && this.connection === target) this.close(); }, 5000).unref(); + } + } + private async save(interrupted: boolean): Promise { + const temp = `${this.stateFile}.tmp`; + await writeFile(temp, JSON.stringify({ sessionId: this.sessionId, summary: this.summary, interrupted }), { mode: 0o600 }); + await rename(temp, this.stateFile); + } + close(): void { + this.connection?.close(); this.connection = undefined; + const child = this.child; + if (child) { + terminateAgentTree(child, 'SIGTERM'); + setTimeout(() => terminateAgentTree(child, 'SIGKILL'), 3000).unref(); + } + this.child = undefined; this.sessionId = undefined; + } +} + +function terminateAgentTree(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void { + try { + // The adapter launches Codex and MCP children. On Unix all are in our own process group. + if (process.platform !== 'win32' && child.pid) process.kill(-child.pid, signal); + else if (child.exitCode === null) child.kill(signal); + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ESRCH') child.kill(signal); } +} diff --git a/bridge/src/backend.ts b/bridge/src/backend.ts new file mode 100644 index 0000000..a30a51a --- /dev/null +++ b/bridge/src/backend.ts @@ -0,0 +1,90 @@ +import { randomUUID } from 'node:crypto'; + +export class BackendError extends Error { + constructor(public readonly code: string, message: string, public readonly details?: unknown) { + super(message); this.name = 'BackendError'; + } +} +export interface Scope { player_id: string; project_id: string } +export interface BackendOptions { + url: string; token: string; scope?: Scope; timeoutMs?: number; maxResponseBytes?: number; maxRequestBytes?: number; +} +export interface RpcBackend { call(method: string, params?: Record): Promise } + +export function validateBackendUrl(raw: string): URL { + const url = new URL(raw); + if (!['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname) || url.protocol !== 'http:') { + throw new Error('MCB_BACKEND_URL must use HTTP on loopback. Use an SSH tunnel for remote Paper.'); + } + if (url.username || url.password || url.search || url.hash || !['/', ''].includes(url.pathname)) { + throw new Error('MCB_BACKEND_URL must contain only a loopback origin.'); + } + return url; +} + +export class BackendClient implements RpcBackend { + readonly url: URL; + constructor(private readonly options: BackendOptions) { + this.url = validateBackendUrl(options.url); + if (!options.token || /[\r\n]/.test(options.token)) throw new Error('Backend token is required.'); + } + async call(method: string, params: Record = {}): Promise { + // Scope always comes from the launcher, never from model-controlled arguments. + const body = JSON.stringify({ method, params: { ...params, ...this.options.scope }, requestId: randomUUID() }); + if (Buffer.byteLength(body) > (this.options.maxRequestBytes ?? 1_048_576)) { + throw new BackendError('request_too_large', 'Request exceeds the bridge byte limit.'); + } + return this.request('/v1/rpc', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${this.options.token}` }, body }, true, method === 'camera_capture' ? 12_582_912 : undefined, method === 'camera_capture' ? 60_000 : undefined); + } + async health(): Promise { return this.request('/health', { method: 'GET' }, false); } + private async request(path: string, init: RequestInit, envelope = true, responseLimit?: number, requestTimeout?: number): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), this.options.timeoutMs ?? requestTimeout ?? 15_000); + try { + const response = await fetch(new URL(path, this.url), { ...init, signal: controller.signal, redirect: 'error' }); + const limit = this.options.maxResponseBytes ?? responseLimit ?? 2_097_152; + if (Number(response.headers.get('content-length')) > limit) { + await response.body?.cancel(); + throw new BackendError('response_too_large', 'Backend response exceeds the bridge byte limit.'); + } + const reader = response.body?.getReader(); + if (!reader) throw new BackendError('invalid_response', 'Backend returned an empty response.'); + let bytes = 0; const chunks: Uint8Array[] = []; + for (;;) { + const { value, done } = await reader.read(); if (done) break; + bytes += value.byteLength; + if (bytes > limit) { await reader.cancel(); throw new BackendError('response_too_large', 'Backend response exceeds the bridge byte limit.'); } + chunks.push(value); + } + let data: unknown; + try { data = JSON.parse(Buffer.concat(chunks).toString('utf8')); } + catch { throw new BackendError('invalid_response', 'Backend did not return valid JSON.'); } + if (!envelope) { + if (!response.ok) throw new BackendError(`http_${response.status}`, `Backend HTTP ${response.status}.`); + return data; + } + if (!data || typeof data !== 'object' || !('ok' in data)) throw new BackendError('invalid_response', 'Backend response has no RPC envelope.'); + if (data.ok === true && 'result' in data && response.ok) return data.result; + if (data.ok === false && 'error' in data && data.error && typeof data.error === 'object') { + const error = data.error as Record; + throw new BackendError(typeof error.code === 'string' ? error.code : 'backend_error', + this.redact(typeof error.message === 'string' ? error.message : 'Backend rejected the request.'), + error.details ? JSON.parse(this.redact(JSON.stringify(error.details))) : undefined); + } + throw new BackendError('invalid_response', 'Backend returned an invalid RPC envelope.'); + } catch (error) { + if (error instanceof BackendError) throw error; + if (controller.signal.aborted) throw new BackendError('timeout', 'Backend request timed out. A write may have started; query operation status before retrying with the same idempotency key.'); + throw new BackendError('unavailable', 'Cannot reach the Paper backend. Check its process and local port.'); + } finally { clearTimeout(timer); } + } + private redact(value: string): string { return value.split(this.options.token).join('[REDACTED]'); } +} + +export function backendFromEnv(env = process.env, admin = false): BackendClient { + const token = admin ? env.MCB_TOKEN : env.MCB_AGENT_TOKEN; + if (!token) throw new Error(`${admin ? 'MCB_TOKEN' : 'MCB_AGENT_TOKEN'} is required; tokens are separate for chat and tools.`); + if (!admin && (!env.MCB_PLAYER_ID || !env.MCB_PROJECT_ID)) throw new Error('MCB_PLAYER_ID and MCB_PROJECT_ID are required for MCP tools.'); + return new BackendClient({ url: env.MCB_BACKEND_URL ?? 'http://127.0.0.1:8765', token, + scope: admin ? undefined : { player_id: env.MCB_PLAYER_ID!, project_id: env.MCB_PROJECT_ID! } }); +} diff --git a/bridge/src/chat-runner.ts b/bridge/src/chat-runner.ts new file mode 100644 index 0000000..88f768a --- /dev/null +++ b/bridge/src/chat-runner.ts @@ -0,0 +1,110 @@ +import { randomUUID } from 'node:crypto'; +import { setTimeout as pause } from 'node:timers/promises'; +import { type AgentSession, type ChatMessage, type Reply } from './acp.js'; +import { BackendError, type RpcBackend } from './backend.js'; + +export interface ChatRunnerOptions { maxQueue?: number; maxSessions?: number; onError?: (code: string) => void } +export class ChatRunner { + private readonly queues = new Map(); + private readonly running = new Set(); + private readonly sessions = new Map(); + private readonly active = new Map(); + private readonly seen = new Set(); + private stopping = false; + private readonly clientId = randomUUID(); + private readonly delivery = new Map>(); + private readonly replyTime = new Map(); + constructor(private readonly backend: RpcBackend, private readonly factory: (message: ChatMessage) => AgentSession, private readonly options: ChatRunnerOptions = {}) {} + async poll(): Promise { + const response = await this.backend.call('chat_poll', { client_id: this.clientId }); + if (!response || typeof response !== 'object' || !('messages' in response) || !Array.isArray(response.messages)) throw new Error('Invalid chat_poll response.'); + for (const raw of response.messages) { + if (!isChatMessage(raw)) { this.options.onError?.('invalid_chat_message'); continue; } + if (this.seen.has(raw.id)) continue; + this.seen.add(raw.id); + if (this.seen.size > 10_000) this.seen.delete(this.seen.values().next().value!); + if (raw.type === 'cancel') { await this.cancel(raw); continue; } + if (raw.type === 'reset') { + await this.reply(raw)('Сброс диалога пока не поддерживается. После перезапуска мост попытается возобновить сессию.', true, true); continue; + } + const queue = this.queues.get(raw.projectId) ?? []; + if (queue.length >= (this.options.maxQueue ?? 8)) { await this.reply(raw)('Очередь проекта заполнена. Повтори запрос после завершения текущего.', true, true); continue; } + queue.push(raw); this.queues.set(raw.projectId, queue); + if (!this.running.has(raw.projectId)) void this.drain(raw.projectId); + } + } + private reply(message: ChatMessage): Reply { + return async (text, done, error) => { + const channel = `${message.projectId}\0${message.playerId}`; + const next = (this.delivery.get(channel) ?? Promise.resolve()).catch(() => {}).then(async () => { + const delay = (this.replyTime.get(channel) ?? 0) + 250 - Date.now(); + if (text && delay > 0) await pause(delay); + await this.backend.call('chat_reply', { id: message.id, playerId: message.playerId, text, done, ...(error ? { error: true } : {}) }); + this.replyTime.set(channel, Date.now()); + }); + this.delivery.set(channel, next); + try { await next; } finally { if (this.delivery.get(channel) === next) this.delivery.delete(channel); } + }; + } + private async drain(projectId: string): Promise { + this.running.add(projectId); + try { + for (;;) { + if (this.stopping) break; + const message = this.queues.get(projectId)?.shift(); if (!message) break; + const key = `${projectId}\0${message.playerId}`; + let session = this.sessions.get(key); + try { + if (!session) { + if (this.sessions.size >= (this.options.maxSessions ?? 8)) throw new Error('Session capacity reached.'); + session = this.factory(message); this.sessions.set(key, session); + } + this.active.set(projectId, { message, session }); + await this.reply(message)('Codex обрабатывает запрос. Остановка: /ai stop.', false); + await session.prompt(promptWithContext(message), this.reply(message)); + } catch (error) { + session?.close(); this.sessions.delete(key); + const code = error instanceof BackendError ? error.code : 'acp_error'; + this.options.onError?.(code); + await this.reply(message)(`Запрос не завершён (${code}). Проверь вход Codex и настройки ACP; изменения мира проверь через /ai status.`, true, true).catch(() => this.options.onError?.('chat_reply_failed')); + } finally { this.active.delete(projectId); } + } + } finally { this.running.delete(projectId); if (!(this.queues.get(projectId)?.length)) this.queues.delete(projectId); } + } + private async cancel(message: ChatMessage): Promise { + const current = this.active.get(message.projectId); + if (current && current.message.playerId === message.playerId) await current.session.cancel().catch(() => current.session.close()); + const queue = this.queues.get(message.projectId) ?? []; + const cancelled = queue.filter(item => item.playerId === message.playerId); + this.queues.set(message.projectId, queue.filter(item => item.playerId !== message.playerId)); + for (const item of cancelled) await this.reply(item)('Запрос удалён из очереди.', true); + // Paper's /ai stop independently cancels any world operation. ACP cancellation alone is insufficient. + await this.reply(message)('Остановка Codex запрошена; остановку записи выполняет сервер.', true); + } + close(): void { + this.stopping = true; + for (const session of this.sessions.values()) session.close(); + } +} +function isChatMessage(value: unknown): value is ChatMessage { + if (!value || typeof value !== 'object') return false; + const item = value as Record; + return ['id','playerId','projectId'].every(key => typeof item[key] === 'string' && (item[key] as string).length > 0 && (item[key] as string).length <= 200) + && typeof item.text === 'string' && item.text.length <= 8000 && (item.type === undefined || ['prompt','cancel','reset'].includes(String(item.type))); +} + +export function promptWithContext(message: ChatMessage): string { + const context: Record = {}; + function position(raw: unknown): Record | undefined { + if (!raw || typeof raw !== 'object') return undefined; + const source = raw as Record; + if (!['x','y','z'].every(key => typeof source[key] === 'number' && Number.isFinite(source[key]))) return undefined; + const result: Record = {}; + for (const key of ['x','y','z','yaw','pitch']) if (typeof source[key] === 'number' && Number.isFinite(source[key])) result[key] = source[key]; + return result; + } + if (position(message.playerPosition)) context.playerPosition = position(message.playerPosition); + if (position(message.lookTarget)) context.lookTarget = position(message.lookTarget); + if (typeof message.worldId === 'string' && message.worldId.length <= 128) context.worldId = message.worldId; + return Object.keys(context).length ? `Server-observed player context at request time (verify current project with project_context): ${JSON.stringify(context)}\nPlayer request:\n${message.text}` : message.text; +} diff --git a/bridge/src/chat.ts b/bridge/src/chat.ts new file mode 100644 index 0000000..e539baa --- /dev/null +++ b/bridge/src/chat.ts @@ -0,0 +1,37 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { setTimeout as pause } from 'node:timers/promises'; +import { backendFromEnv } from './backend.js'; +import { CodexSession, type AcpOptions } from './acp.js'; +import { ChatRunner } from './chat-runner.js'; + +let runner: ChatRunner | undefined; +try { + if (!process.env.MCB_AGENT_TOKEN) throw new Error('MCB_AGENT_TOKEN is required for the agent MCP connection.'); + let args: string[] | undefined; + if (process.env.MCB_ACP_ARGS) { + const parsed: unknown = JSON.parse(process.env.MCB_ACP_ARGS); + if (!Array.isArray(parsed) || !parsed.every(value => typeof value === 'string')) throw new Error('MCB_ACP_ARGS must be a JSON array of strings.'); + args = parsed; + } + const options: AcpOptions = { + backendUrl: process.env.MCB_BACKEND_URL ?? 'http://127.0.0.1:8765', + agentToken: process.env.MCB_AGENT_TOKEN, + stateDir: resolve(process.env.MCB_STATE_DIR ?? '.state/chat'), + command: process.env.MCB_ACP_COMMAND, args, + codexHome: process.env.MCB_CODEX_HOME, model: process.env.MCB_MODEL, authMethod: process.env.MCB_ACP_AUTH_METHOD, + }; + const backend = backendFromEnv(process.env, true); + runner = new ChatRunner(backend, message => new CodexSession(message, options), { onError: code => console.error(`minecraft-builder-mcp: ${code}`) }); + let stopping = false; + const stop = () => { stopping = true; runner?.close(); }; + process.once('SIGINT', stop); process.once('SIGTERM', stop); + console.error('minecraft-builder-mcp chat bridge started (local Paper, ACP).'); + let failures = 0; + while (!stopping) { + try { await runner.poll(); failures = 0; } + catch { failures++; if (failures === 1 || failures % 30 === 0) console.error('Paper chat polling failed; check local backend and admin token.'); } + if (!stopping) await pause(Math.min(10_000, 750 * Math.max(1, failures))); + } +} catch (error) { console.error(error instanceof Error ? error.message : 'Chat bridge failed.'); process.exitCode = 1; } +finally { runner?.close(); } diff --git a/bridge/src/doctor.ts b/bridge/src/doctor.ts new file mode 100644 index 0000000..a0049dd --- /dev/null +++ b/bridge/src/doctor.ts @@ -0,0 +1,13 @@ +#!/usr/bin/env node +import { resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { backendFromEnv } from './backend.js'; +import { codexPaths, prepareCodexHome, securityReport } from './security.js'; +const paths = codexPaths(resolve(process.env.MCB_STATE_DIR ?? '.state/chat'), process.env.MCB_CODEX_HOME); +try { + await prepareCodexHome(paths); + let backend: unknown; + try { backend = await backendFromEnv(process.env, true).health(); } + catch (error) { backend = { status: 'unavailable', message: error instanceof Error ? error.message : 'Health check failed.' }; } + console.log(JSON.stringify({ bridge: '0.1.0', node: process.version, backend, codex: { ...securityReport(paths), login: { command: process.execPath, args: [fileURLToPath(new URL('./login.js', import.meta.url))], env: { MCB_STATE_DIR: resolve(process.env.MCB_STATE_DIR ?? '.state/chat'), MCB_CODEX_HOME: paths.codexHome }, method: 'device-auth (starts only when the user runs this command)' } } }, null, 2)); +} catch (error) { console.error(error instanceof Error ? error.message : 'Doctor failed.'); process.exitCode = 1; } diff --git a/bridge/src/login.ts b/bridge/src/login.ts new file mode 100644 index 0000000..e3122ca --- /dev/null +++ b/bridge/src/login.ts @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import { createRequire } from 'node:module'; +import { resolve } from 'node:path'; +import { agentEnvironment, codexPaths, prepareCodexHome } from './security.js'; +try { + const args = process.argv.slice(2); + if (args.length > 1 || (args[0] !== undefined && args[0] !== 'status')) throw new Error('Usage: npm run login [-- status]'); + const paths = codexPaths(resolve(process.env.MCB_STATE_DIR ?? '.state/chat'), process.env.MCB_CODEX_HOME); + await prepareCodexHome(paths); + const require = createRequire(import.meta.url); + const child = spawn(process.execPath, [require.resolve('@openai/codex/bin/codex.js'), 'login', ...(args[0] === 'status' ? ['status'] : ['--device-auth'])], { stdio: 'inherit', env: agentEnvironment(process.env, paths), shell: false }); + child.once('error', () => { console.error('Unable to launch the pinned Codex login command.'); process.exitCode = 1; }); + child.once('exit', code => { process.exitCode = code ?? 1; }); +} catch (error) { console.error(error instanceof Error ? error.message : 'Codex login helper failed.'); process.exitCode = 1; } diff --git a/bridge/src/mcp.ts b/bridge/src/mcp.ts new file mode 100644 index 0000000..fcadb3e --- /dev/null +++ b/bridge/src/mcp.ts @@ -0,0 +1,10 @@ +#!/usr/bin/env node +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { backendFromEnv } from './backend.js'; +import { createMcpServer } from './tools.js'; +try { + const server = createMcpServer(backendFromEnv()); + await server.connect(new StdioServerTransport()); + const shutdown = () => { void server.close().finally(() => process.exit(0)); }; + process.once('SIGINT', shutdown); process.once('SIGTERM', shutdown); +} catch (error) { console.error(error instanceof Error ? error.message : 'MCP startup failed.'); process.exitCode = 1; } diff --git a/bridge/src/rpc.ts b/bridge/src/rpc.ts new file mode 100644 index 0000000..09bbec3 --- /dev/null +++ b/bridge/src/rpc.ts @@ -0,0 +1,9 @@ +#!/usr/bin/env node +import { backendFromEnv } from './backend.js'; +try { + const [method, raw = '{}'] = process.argv.slice(2); + if (!method || !/^[a-z_]+$/.test(method)) throw new Error('Usage: node dist/rpc.js method \'{"params":"values"}\''); + const params: unknown = JSON.parse(raw); + if (!params || typeof params !== 'object' || Array.isArray(params)) throw new Error('RPC params must be a JSON object.'); + console.log(JSON.stringify(await backendFromEnv().call(method, params as Record), null, 2)); +} catch (error) { console.error(error instanceof Error ? error.message : 'RPC failed.'); process.exitCode = 1; } diff --git a/bridge/src/security.ts b/bridge/src/security.ts new file mode 100644 index 0000000..eec6861 --- /dev/null +++ b/bridge/src/security.ts @@ -0,0 +1,76 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises'; +import { delimiter, dirname, join, resolve } from 'node:path'; + +// These are supported by the pinned Codex 0.153.4 runtime and official configuration reference. +export const CODEX_CONFIG = { + sandbox_mode: 'read-only', approval_policy: 'on-request', approvals_reviewer: 'user', + cli_auth_credentials_store: 'file', allow_login_shell: false, web_search: 'disabled', + sandbox_workspace_write: { network_access: false, writable_roots: [], exclude_slash_tmp: true, exclude_tmpdir_env_var: true }, + shell_environment_policy: { inherit: 'none' }, + features: { + shell_tool: false, unified_exec: false, shell_snapshot: false, + apps: false, hooks: false, multi_agent: false, plugins: false, remote_plugin: false, + browser_use: false, browser_use_external: false, browser_use_full_cdp_access: false, + computer_use: false, image_generation: false, code_mode: false, code_mode_host: false, + skill_mcp_dependency_install: false, + }, +}; +export const CODEX_CONFIG_TOML = `# Managed by minecraft-builder-mcp; use a dedicated CODEX_HOME.\nsandbox_mode = "read-only"\napproval_policy = "on-request"\napprovals_reviewer = "user"\ncli_auth_credentials_store = "file"\nallow_login_shell = false\nweb_search = "disabled"\n\n[sandbox_workspace_write]\nnetwork_access = false\nwritable_roots = []\nexclude_slash_tmp = true\nexclude_tmpdir_env_var = true\n\n[shell_environment_policy]\ninherit = "none"\n\n[features]\n${Object.entries(CODEX_CONFIG.features).map(([key,value]) => `${key} = ${value}`).join('\n')}\n`; + +export interface CodexPaths { home: string; codexHome: string } +export function codexPaths(stateDir: string, codexHome?: string): CodexPaths { + const state = resolve(stateDir); + return { home: join(state, 'home'), codexHome: resolve(codexHome ?? join(state, 'codex-home')) }; +} +export async function prepareCodexHome(paths: CodexPaths): Promise { + for (const directory of [paths.home, paths.codexHome, join(paths.home,'.config'), join(paths.home,'.cache'), join(paths.home,'tmp')]) await mkdir(directory,{recursive:true,mode:0o700}); + const configPath = join(paths.codexHome, 'config.toml'); + let existing: string | undefined; + try { existing = await readFile(configPath, 'utf8'); } + catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + if (existing !== undefined && existing !== CODEX_CONFIG_TOML) { + throw new Error('MCB_CODEX_HOME contains a different config.toml. Choose a dedicated empty Codex home or the bridge-managed home; existing settings will not be overwritten.'); + } + if (existing === undefined) { + try { await writeFile(configPath, CODEX_CONFIG_TOML, { flag: 'wx', mode: 0o600 }); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || await readFile(configPath,'utf8') !== CODEX_CONFIG_TOML) throw error; + } + } +} + +/** Explicit allowlist; arbitrary user cloud credentials, global Codex settings and bridge tokens stay outside the child. */ +export function agentEnvironment(source: NodeJS.ProcessEnv, paths: CodexPaths): NodeJS.ProcessEnv { + const result: NodeJS.ProcessEnv = {}; + for (const key of ['LANG','LC_ALL','LC_CTYPE','TZ','SystemRoot','WINDIR','PATHEXT']) if (source[key] !== undefined) result[key] = source[key]; + result.PATH = [dirname(process.execPath), ...(source.PATH ? [source.PATH] : process.platform === 'win32' ? [] : ['/usr/bin','/bin'])].join(delimiter); + result.HOME = paths.home; result.USERPROFILE = paths.home; + result.XDG_CONFIG_HOME = join(paths.home,'.config'); result.XDG_CACHE_HOME = join(paths.home,'.cache'); + result.APPDATA = join(paths.home,'.config'); result.LOCALAPPDATA = join(paths.home,'.cache'); + result.TMPDIR = join(paths.home,'tmp'); result.TEMP = result.TMPDIR; result.TMP = result.TMPDIR; + result.CODEX_HOME = paths.codexHome; + result.CODEX_CONFIG = JSON.stringify(CODEX_CONFIG); + result.INITIAL_AGENT_MODE = 'read-only'; result.NO_BROWSER = '1'; + if (source.MCB_OPENAI_API_KEY) result.OPENAI_API_KEY = source.MCB_OPENAI_API_KEY; + if (source.MCB_CODEX_API_KEY) result.CODEX_API_KEY = source.MCB_CODEX_API_KEY; + return result; +} +export function securityReport(paths: CodexPaths) { + return { + codexHome: paths.codexHome, osHome: paths.home, globalUserConfigurationInherited: false, + authentication: 'Dedicated login or explicitly supplied MCB_OPENAI_API_KEY/MCB_CODEX_API_KEY; never copied automatically', + configuredSandbox: 'read-only', adapterMode: 'read-only', adapterVersion: '1.11.0', + adapterTurnSandbox: 'workspace-write', approvalPolicy: 'on-request', approvalsReviewer: 'user', + sandboxedCommandNetwork: false, shellToolRequested: false, unifiedExecRequested: false, + pinnedCliFeatureProbe: { codexVersion: '0.153.4', platform: 'linux', shell_tool: false, unified_exec: true }, + inheritedMcpServers: false, acpFilesystem: false, acpTerminal: false, + runtimeVerified: false, + limitations: [ + 'Pinned Codex reports unified_exec enabled even when disabled explicitly; shell_tool is disabled. A real model tool-availability check has not run.', + 'codex-acp 1.11.0 overrides turn sandbox to workspace-write even in its read-only mode; the session directory and temporary paths may remain writable.', + 'Sandboxed-command network restrictions do not sandbox the bridge, ACP adapter or MCP server processes; these remain trusted local programs.', + 'No real model turn or end-to-end sandbox probe has run; configuration and initialization alone do not prove OS-level isolation.', + 'Administrator-managed Codex settings and repository-local configuration can also apply; use the dedicated project/state directory and inspect doctor output.', + ], + }; +} diff --git a/bridge/src/tools.ts b/bridge/src/tools.ts new file mode 100644 index 0000000..b940c52 --- /dev/null +++ b/bridge/src/tools.ts @@ -0,0 +1,64 @@ +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'; +import { z } from 'zod'; +import { BackendError, type RpcBackend } from './backend.js'; + +const id = z.string().min(1).max(200); +const position = z.object({ x: z.number().int().min(-30_000_000).max(30_000_000), y: z.number().int().min(-4096).max(4096), z: z.number().int().min(-30_000_000).max(30_000_000) }).strict(); +const block = z.string().regex(/^minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?$/).max(200); +// Deliberate small declarative language: the Paper compiler enforces all resource limits again. +const shape = z.discriminatedUnion('type', [ + z.object({ type: z.literal('box'), min: position, max: position, block, hollow: z.boolean().optional() }).strict(), + z.object({ type: z.literal('line'), from: position, to: position, block }).strict(), + z.object({ type: z.literal('cylinder'), center: position, radius: z.number().int().min(0).max(128), height: z.number().int().min(1).max(256), block, hollow: z.boolean().optional() }).strict(), +]); +const operation = z.union([shape, z.object({ type: z.literal('repeat'), count: z.number().int().min(1).max(128), offset: position, operations: z.array(shape).min(1).max(256) }).strict()]); +const recipe = z.object({ version: z.literal(1), operations: z.array(operation).min(1).max(256) }).strict(); +const pose = z.object({ x: z.number().finite(), y: z.number().finite(), z: z.number().finite(), yaw: z.number().min(-360).max(360), pitch: z.number().min(-90).max(90), fov: z.number().int().min(30).max(110).optional(), width: z.number().int().min(320).max(1920).optional(), height: z.number().int().min(180).max(1080).optional() }).strict(); + +export function toolResult(result: unknown): CallToolResult { + let metadata = result; + const content: CallToolResult['content'] = []; + if (result && typeof result === 'object' && 'imageBase64' in result) { + const { imageBase64, mimeType, ...rest } = result as Record; + if (rest.status !== 'completed' || typeof imageBase64 !== 'string' || imageBase64.length > 12_000_000 || !['image/png', 'image/jpeg'].includes(String(mimeType)) || !/^[A-Za-z0-9+/]*={0,2}$/.test(imageBase64)) { + throw new BackendError('invalid_image', 'Camera returned an invalid or oversized capture.'); + } + metadata = rest; + content.push({ type: 'image', data: imageBase64, mimeType: String(mimeType) }); + } + const text = JSON.stringify(metadata ?? null); + if (Buffer.byteLength(text) > 65_536) throw new BackendError('context_limit', 'Result is too large for model context. Request a smaller region or summary.'); + content.unshift({ type: 'text', text }); + return { content }; +} + +export function createMcpServer(backend: RpcBackend): McpServer { + const server = new McpServer({ name: 'minecraft-builder-mcp', version: '0.1.0' }, { instructions: + 'Build only through these tools in the server-authorized project. Start with project_context and region_inspect. Prepare a compact recipe, inspect its summary, then apply with the returned plan ID/hash and a stable unique idempotency key. Conflicts preserve manual edits: never re-read and blindly overwrite them. Query operation_status until terminal; cancellation can leave partial edits. Camera unavailable means visually unverified. Never claim success from preparation alone.' }); + function register(name: string, description: string, inputSchema: z.ZodRawShape, readOnly: boolean, idempotent = false) { + server.registerTool(name, { description, inputSchema, annotations: { readOnlyHint: readOnly, destructiveHint: !readOnly, idempotentHint: idempotent, openWorldHint: false } }, async (args) => { + try { return toolResult(await backend.call(name, args as Record)); } + catch (error) { + const failure = error instanceof BackendError ? { code: error.code, message: error.message, details: error.details } : { code: 'bridge_error', message: 'The bridge could not complete this request.' }; + const text = JSON.stringify(failure); + return { isError: true, content: [{ type: 'text', text: Buffer.byteLength(text) <= 32_768 ? text : JSON.stringify({ code: failure.code, message: failure.message, details: 'Details exceed the context budget; inspect a smaller area.' }) }] }; + } + }); + } + register('project_context', 'Get authorized world, project, area, capabilities, supported blocks, and operation summaries. No full-world dump.', {}, true, true); + register('region_inspect', 'Inspect a bounded inclusive region. Prefer summary; blocks detail is only for a small local area. Both min and max are required; inspect a small section of the project area.', { min: position, max: position, detail: z.enum(['summary', 'blocks']).default('summary') }, true, true); + register('build_prepare', 'Prepare immutable geometry without changing the world. Use supported recipe operations from project_context. Returns plan_id, plan_hash and compact statistics.', { recipe, part_id: id.optional(), dependencies: z.array(position).max(512).optional() }, false); + register('build_apply', 'Apply a prepared plan with compare-before-write protection. Reuse the SAME idempotency_key after uncertain transport outcome; query project_context to recover an unknown operation ID, then operation_status first.', { plan_id: id, plan_hash: id, idempotency_key: id }, false, true); + register('operation_status', 'Get exact state, changed count, conflicts and completion. A terminal cancelled/conflict state can include partial writes.', { operation_id: id }, true, true); + register('operation_cancel', 'Request cancellation before the next server batch. Already applied changes remain journaled.', { operation_id: id }, false, true); + register('operation_undo_prepare', 'Prepare checked undo of a recorded operation. Manual edits after construction become conflicts. Apply returned undo plan with build_apply.', { operation_id: id }, false); + register('part_get', 'Get named part metadata and protected status, without expanding every block.', { part_id: id }, true, true); + register('part_define', 'Register an exact part mask from a completed operation. Server rejects unsupported membership or overlap.', { name: z.string().min(1).max(64), operation_id: id }, false); + register('camera_list', 'List saved camera poses and whether a camera client is connected.', {}, true, true); + register('camera_capture', 'Request a real capture by saved camera_id or pose. A pending response returns captureId; poll using capture_id. Only completed results contain an image. Camera unavailable is not a successful visual check.', { camera_id: id.optional(), pose: pose.optional(), after_operation_id: id.optional(), capture_id: id.optional() }, true); + register('asset_list', 'List up to 64 local schematic assets with dimensions and metadata, optionally filtered by query. No network library or generated thumbnails. Place .schem files manually in the plugin data/schematics directory; asset IDs never accept arbitrary paths.', { query: z.string().max(64).optional() }, true, true); + register('schematic_export', 'Export a dense inclusive region of at most 4096 supported blocks as a local Sponge v2 .schem asset. Optional origin is the clipboard anchor. Returns an asset ID and metadata; files remain in the plugin data/schematics directory.', { name: z.string().min(1).max(64).regex(/^[^\u0000-\u001f\u007f]+$/), min: position, max: position, origin: position.optional() }, false); + register('schematic_import_prepare', 'Prepare a checked import of a local .schem asset at target, optionally rotating 0/90/180/270 degrees. Rejects entities, block entities and unsupported blocks. Returns a normal plan; inspect it and use build_apply to edit the world.', { asset_id: z.string().regex(/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/), target: position, rotation: z.union([z.literal(0),z.literal(90),z.literal(180),z.literal(270)]).default(0) }, false); + return server; +} diff --git a/bridge/test/acp.test.mjs b/bridge/test/acp.test.mjs new file mode 100644 index 0000000..c102b06 --- /dev/null +++ b/bridge/test/acp.test.mjs @@ -0,0 +1,54 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { setTimeout as pause } from 'node:timers/promises'; +import { CodexSession, agentEnvironment } from '../dist/acp.js'; +async function fixture(t){ + const stateDir=await mkdtemp(join(tmpdir(),'mcb-acp-'));t.after(()=>rm(stateDir,{recursive:true,force:true})); + const log=join(stateDir,'protocol.jsonl'); + const options={backendUrl:'http://127.0.0.1:8765',agentToken:'agent-secret',stateDir,command:process.execPath,args:[resolve('test/fixtures/mock-acp.mjs'),log],env:{...process.env,MCB_TOKEN:'ADMIN SECRET'}}; + const records=async()=> (await readFile(log,'utf8').catch(()=>'' )).trim().split('\n').filter(Boolean).map(JSON.parse); + return {options,records}; +} +test('ACP initializes, injects scoped MCP, preserves sessions, suppresses private replay and thoughts',async t=>{ + const {options,records}=await fixture(t);const messages=[]; + let session=new CodexSession({projectId:'project',playerId:'player'},options);t.after(()=>session.close()); + await session.prompt('Build a tower',async(text,done)=>messages.push({text,done})); + await session.prompt('Make it higher',async(text,done)=>messages.push({text,done})); + assert.ok(messages.some(item=>item.text.includes('Built turn 2'))); + assert.ok(!messages.some(item=>item.text.includes('SECRET THOUGHT'))); + session.close();session=new CodexSession({projectId:'project',playerId:'player'},options); + await session.prompt('Resume',async(text,done)=>messages.push({text,done})); + assert.ok(!messages.some(item=>item.text.includes('PRIVATE HISTORY'))); + const all=await records();assert.equal(all.filter(item=>item.method==='session/new').length,1);assert.equal(all.filter(item=>item.method==='session/load').length,1); + const mcp=all.find(item=>item.method==='session/new').params.mcpServers[0]; + assert.ok(mcp.env.some(item=>item.name==='MCB_PLAYER_ID'&&item.value==='player')); + assert.ok(!JSON.stringify(all).includes('ADMIN SECRET')); +}); +test('permission requests fail closed and explain in chat',async t=>{ + const {options,records}=await fixture(t);const messages=[]; + const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + await session.prompt('request-permission',async text=>messages.push(text)); + assert.ok(messages.some(text=>text.includes('отклонено'))); + assert.equal((await records()).find(item=>item.id==='approval').result.outcome.outcome,'cancelled'); +}); +test('ACP cancel completes active prompt without ending the whole daemon',async t=>{ + const {options,records}=await fixture(t);const messages=[]; + const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + const work=session.prompt('wait-for-cancel',async text=>messages.push(text)); + for(let i=0;i<100;i++){await pause(10);if((await records()).some(item=>item.method==='session/prompt'))break;} + await session.cancel();await work; + assert.ok(messages.some(text=>text.includes('Остановлено'))); +}); +test('child environment allowlists secrets and forces dedicated Codex configuration',()=>{ + const env=agentEnvironment({PATH:'/bin',HOME:'/home/test',MCB_TOKEN:'admin',MCB_AGENT_TOKEN:'agent',AWS_SECRET_ACCESS_KEY:'private',OPENAI_API_KEY:'inherited',MCB_OPENAI_API_KEY:'opt-in',CODEX_CONFIG:'{"sandbox_mode":"danger-full-access"}'},{home:'/isolated/home',codexHome:'/isolated/codex'}); + assert.equal(env.HOME,'/isolated/home');assert.equal(env.CODEX_HOME,'/isolated/codex');assert.equal(env.AWS_SECRET_ACCESS_KEY,undefined);assert.equal(env.MCB_TOKEN,undefined);assert.equal(env.OPENAI_API_KEY,'opt-in');assert.equal(JSON.parse(env.CODEX_CONFIG).features.shell_tool,false);assert.equal(env.INITIAL_AGENT_MODE,'read-only'); +}); + +test('cancel during startup does not send a prompt after initialization completes',async t=>{ + const {options,records}=await fixture(t);const session=new CodexSession({projectId:'p',playerId:'u'},options);t.after(()=>session.close()); + const work=session.prompt('must-not-send',async()=>{});await session.cancel();await work; + assert.ok(!(await records()).some(item=>item.method==='session/prompt')); +}); diff --git a/bridge/test/backend.test.mjs b/bridge/test/backend.test.mjs new file mode 100644 index 0000000..ac93d2c --- /dev/null +++ b/bridge/test/backend.test.mjs @@ -0,0 +1,45 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { BackendClient, backendFromEnv } from '../dist/backend.js'; + +async function fixture(t, handler) { + const server = createServer(handler); server.listen(0, '127.0.0.1'); await once(server, 'listening'); + t.after(() => { server.closeAllConnections(); server.close(); }); + return `http://127.0.0.1:${server.address().port}`; +} +test('transport injects trusted actor, authenticates and uses stable envelope', async t => { + let request; + const url = await fixture(t, async (req,res) => { + assert.equal(req.headers.authorization,'Bearer private-token'); + let body = ''; for await (const chunk of req) body += chunk; + request = JSON.parse(body); res.end(JSON.stringify({ok:true,result:{project:'p'}})); + }); + const backend = new BackendClient({ url, token:'private-token', scope:{player_id:'owner',project_id:'p'} }); + assert.deepEqual(await backend.call('project_context',{player_id:'forged',project_id:'elsewhere'}),{project:'p'}); + assert.equal(request.params.player_id,'owner'); assert.equal(request.params.project_id,'p'); assert.ok(request.requestId); +}); +test('backend error details redact the token', async t => { + const url = await fixture(t, (_req,res) => {res.statusCode=400;res.end(JSON.stringify({ok:false,error:{code:'conflict',message:'private-token is hidden',details:{token:'private-token'}}}));}); + const backend = new BackendClient({url,token:'private-token'}); + await assert.rejects(backend.call('build_apply'),error => error.code==='conflict' && !error.message.includes('private-token') && error.details.token==='[REDACTED]'); +}); +test('response is bounded even with streaming and no content-length', async t => { + const url = await fixture(t, (_req,res) => { res.write('x'.repeat(300)); res.end('x'.repeat(300)); }); + await assert.rejects(new BackendClient({url,token:'token',maxResponseBytes:100}).call('project_context'),error=>error.code==='response_too_large'); +}); +test('write timeout is not automatically retried', async t => { + let calls=0; const url = await fixture(t, () => { calls++; }); + await assert.rejects(new BackendClient({url,token:'token',timeoutMs:30}).call('build_apply'),error=>error.code==='timeout'); + assert.equal(calls,1); +}); +test('token cannot follow a redirect, invalid JSON cannot masquerade as result', async t => { + const url = await fixture(t, (_req,res) => {res.writeHead(302,{location:'http://127.0.0.1:1'});res.end();}); + await assert.rejects(new BackendClient({url,token:'token'}).call('project_context'),error=>error.code==='unavailable'); +}); +test('remote endpoints and missing or conflated auth are rejected', () => { + assert.throws(()=>new BackendClient({url:'http://example.com',token:'token'}),/loopback/); + assert.throws(()=>backendFromEnv({MCB_TOKEN:'admin'}),/MCB_AGENT_TOKEN/); + assert.throws(()=>backendFromEnv({MCB_AGENT_TOKEN:'agent'}),/MCB_PLAYER_ID/); +}); diff --git a/bridge/test/chat-runner.test.mjs b/bridge/test/chat-runner.test.mjs new file mode 100644 index 0000000..2a14bb6 --- /dev/null +++ b/bridge/test/chat-runner.test.mjs @@ -0,0 +1,37 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { setTimeout as pause } from 'node:timers/promises'; +import { ChatRunner, promptWithContext } from '../dist/chat-runner.js'; + +const message=(id,projectId='p',playerId='u',type='prompt')=>({id,projectId,playerId,text:id,type}); +async function eventually(predicate){for(let i=0;i<400;i++){if(predicate())return;await pause(5);}assert.fail('Condition timed out');} +test('projects run concurrently while each project stays ordered, replies remain private',async()=>{ + const polls=[[message('one'),message('two'),message('other','q','v')]];const replies=[];const started=[];const release=new Map(); + const backend={async call(method,params){if(method==='chat_poll'){assert.match(params.client_id,/^[0-9a-f-]{36}$/);return {messages:polls.shift()??[]};}replies.push(params);return {};}}; + const runner=new ChatRunner(backend,()=>({async prompt(text,reply){started.push(text);await new Promise(done=>release.set(text,done));await reply(`answer ${text}`,true);},async cancel(){},close(){}})); + try{ + await runner.poll();await eventually(()=>started.length===2); + assert.deepEqual(started,['one','other']);release.get('one')();await eventually(()=>started.includes('two')); + release.get('two')();release.get('other')();await eventually(()=>replies.filter(item=>item.done).length===3); + assert.equal(replies.find(item=>item.text==='answer other').playerId,'v'); + assert.equal(replies.find(item=>item.text==='answer two').playerId,'u'); + }finally{runner.close();} +}); +test('stop reaches active session during prompt and drops only that player queued messages',async()=>{ + const polls=[[message('one'),message('two')],[message('stop','p','u','cancel')]];const replies=[];let release;let cancelled=0; + const backend={async call(method,params){if(method==='chat_poll'){assert.match(params.client_id,/^[0-9a-f-]{36}$/);return {messages:polls.shift()??[]};}replies.push(params);return {};}}; + const runner=new ChatRunner(backend,()=>({async prompt(text,reply){await new Promise(done=>release=done);await reply('stopped',true);},async cancel(){cancelled++;release();},close(){}})); + try{await runner.poll();await eventually(()=>release);await runner.poll();assert.equal(cancelled,1);assert.ok(replies.some(item=>item.id==='two'&&item.done));}finally{runner.close();} +}); +test('replayed polling message ID does not start duplicate agent turn',async()=>{ + let calls=0;const request=message('one'); + const backend={async call(method){return method==='chat_poll'?{messages:[request]}:{};}}; + const runner=new ChatRunner(backend,()=>({async prompt(){calls++;},async cancel(){},close(){}})); + try{await runner.poll();await pause(1);await runner.poll();await pause(1);assert.equal(calls,1);}finally{runner.close();} +}); + +test('player position, gaze and target are forwarded for each request without arbitrary payload fields',()=>{ + const first=promptWithContext({...message('build here'),playerPosition:{x:1,y:65,z:3,yaw:90,pitch:10,secret:'hidden'},lookTarget:{x:5,y:64,z:8},worldId:'world'}); + assert.ok(first.includes('"x":1'));assert.ok(first.includes('"yaw":90'));assert.ok(first.includes('"lookTarget"'));assert.ok(!first.includes('hidden')); + const next=promptWithContext({...message('build here'),playerPosition:{x:9,y:65,z:3}});assert.ok(next.includes('"x":9')); +}); diff --git a/bridge/test/fixtures/mock-acp.mjs b/bridge/test/fixtures/mock-acp.mjs new file mode 100644 index 0000000..6096dea --- /dev/null +++ b/bridge/test/fixtures/mock-acp.mjs @@ -0,0 +1,24 @@ +import { createInterface } from 'node:readline'; +import { appendFileSync } from 'node:fs'; +const send = value => process.stdout.write(JSON.stringify({jsonrpc:'2.0',...value})+'\n'); +let promptId;let cancelled=false;let turn=0; +for await (const line of createInterface({input:process.stdin})) { + const msg=JSON.parse(line); + if(process.argv[2]) appendFileSync(process.argv[2],JSON.stringify(msg)+'\n'); + if(msg.method==='initialize') send({id:msg.id,result:{protocolVersion:1,agentCapabilities:{loadSession:true},authMethods:[]}}); + else if(msg.method==='session/new') send({id:msg.id,result:{sessionId:'session-one'}}); + else if(msg.method==='session/load') { + send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:'PRIVATE HISTORY'}}}}); + send({id:msg.id,result:{}}); + } + else if(msg.method==='session/prompt') { + turn++;promptId=msg.id; + const content=msg.params.prompt[0].text; + if(content.includes('wait-for-cancel')) continue; + if(content.includes('request-permission')) {send({id:'approval',method:'session/request_permission',params:{sessionId:'session-one',toolCall:{toolCallId:'dangerous',title:'Permission',kind:'execute'},options:[{optionId:'yes',name:'Allow',kind:'allow_once'}]}});continue;} + send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_thought_chunk',content:{type:'text',text:'SECRET THOUGHT'}}}}); + send({method:'session/update',params:{sessionId:'session-one',update:{sessionUpdate:'agent_message_chunk',content:{type:'text',text:`Built turn ${turn}.`}}}}); + send({id:msg.id,result:{stopReason:'end_turn'}}); + } else if(msg.method==='session/cancel') {cancelled=true;send({id:promptId,result:{stopReason:'cancelled'}});} + else if(msg.id==='approval') {send({id:promptId,result:{stopReason:'end_turn'}});} +} diff --git a/bridge/test/launcher.test.mjs b/bridge/test/launcher.test.mjs new file mode 100644 index 0000000..4817892 --- /dev/null +++ b/bridge/test/launcher.test.mjs @@ -0,0 +1,19 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join,resolve} from 'node:path'; +import {spawnSync} from 'node:child_process'; + +test('launcher doctor and login status share a dedicated home without requiring Paper or starting login',async t=>{ + const state=await mkdtemp(join(tmpdir(),'mcb-launcher-'));t.after(()=>rm(state,{recursive:true,force:true})); + const env={...process.env,MCB_STATE_DIR:state,MCB_CODEX_HOME:join(state,'codex-home'),MCB_OPENAI_API_KEY:'',MCB_CODEX_API_KEY:''}; + const run=args=>spawnSync('python3',[resolve('../scripts/bridge.py'),...args,'--config',join(state,'absent-config.yml')],{env,encoding:'utf8',timeout:20_000}); + const doctor=run(['doctor']);assert.equal(doctor.status,0,doctor.stderr); + const data=JSON.parse(doctor.stdout);assert.equal(data.codex.codexHome,env.MCB_CODEX_HOME); + assert.equal(data.codex.login.env.MCB_STATE_DIR,state); + for(const args of [['status'],['login','status'],['login','--status']]){ + const status=run(args);assert.equal(status.status,1,status.stderr);assert.match(status.stdout+status.stderr,/not logged in/i); + assert.ok(!(status.stdout+status.stderr).includes('device code')); + } +}); diff --git a/bridge/test/live-camera.mjs b/bridge/test/live-camera.mjs new file mode 100644 index 0000000..96c4074 --- /dev/null +++ b/bridge/test/live-camera.mjs @@ -0,0 +1,57 @@ +// Opt-in real MCP image check. The configured online spectator is teleported. +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdir, writeFile } from 'node:fs/promises'; +import { resolve } from 'node:path'; +import { setTimeout as pause } from 'node:timers/promises'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +for (const key of ['MCB_AGENT_TOKEN', 'MCB_PLAYER_ID', 'MCB_PROJECT_ID', 'MCB_CAPTURE_POSE']) { + assert.ok(process.env[key], `${key} is required`); +} +const pose = JSON.parse(process.env.MCB_CAPTURE_POSE); +const transport = new StdioClientTransport({ command: process.execPath, args: [resolve('dist/mcp.js')], + env: Object.fromEntries(Object.entries(process.env).filter(([key, value]) => value !== undefined && key !== 'MCB_TOKEN')), stderr: 'pipe' }); +const client = new Client({ name: 'minecraft-builder-live-camera', version: '0.1.0' }); +const startedAt = Date.now(); +async function capture(args) { + const reply = await client.callTool({ name: 'camera_capture', arguments: args }); + assert.notEqual(reply.isError, true, 'Camera MCP returned an error'); + const metadata = JSON.parse(reply.content.find(item => item.type === 'text').text); + assert.equal('imageBase64' in metadata, false, 'Image must be a separate MCP content block'); + return { reply, metadata }; +} +try { + await client.connect(transport); + const args = { pose }; + if (process.env.MCB_AFTER_OPERATION_ID) args.after_operation_id = process.env.MCB_AFTER_OPERATION_ID; + let current = await capture(args); + const id = current.metadata.captureId; + for (let attempt = 0; current.metadata.status === 'pending' && attempt < 150; attempt++) { + await pause(200); + current = await capture({ capture_id: id }); + } + const { reply, metadata } = current; + assert.equal(metadata.status, 'completed', 'A real completed capture is required'); + assert.equal(metadata.captureId, id); + assert.ok(Date.parse(metadata.capturedAt) >= startedAt - 1000, 'Frame must be newly captured'); + const images = reply.content.filter(item => item.type === 'image'); + assert.equal(images.length, 1); + assert.equal(images[0].mimeType, 'image/png'); + const png = Buffer.from(images[0].data, 'base64'); + assert.deepEqual(png.subarray(0, 8), Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])); + assert.equal(png.readUInt32BE(16), metadata.width); + assert.equal(png.readUInt32BE(20), metadata.height); + const directory = resolve(process.env.MCB_CAMERA_OUTPUT_DIR ?? '../.runtime/camera-test'); + await mkdir(directory, { recursive: true }); + const stem = `mcp-${id}`; + const report = { ...metadata, transport: 'MCP stdio ImageContent', imageBytes: png.length, + imageSha256: createHash('sha256').update(png).digest('hex') }; + await writeFile(resolve(directory, `${stem}.png`), png, { mode: 0o600, flag: 'wx' }); + await writeFile(resolve(directory, `${stem}.json`), JSON.stringify(report, null, 2) + '\n', { mode: 0o600, flag: 'wx' }); + console.log(JSON.stringify({ status: 'passed', transport: report.transport, width: metadata.width, + height: metadata.height, imageBytes: png.length, imagePath: resolve(directory, `${stem}.png`) })); +} finally { + await client.close(); +} diff --git a/bridge/test/live-paper.mjs b/bridge/test/live-paper.mjs new file mode 100644 index 0000000..33c8158 --- /dev/null +++ b/bridge/test/live-paper.mjs @@ -0,0 +1,86 @@ +// Opt-in integration driver. It mutates and restores a tiny all-air area on a real development Paper server. +// Credentials arrive only through the environment; this driver never prints them. +import assert from 'node:assert/strict'; +import { randomUUID } from 'node:crypto'; +import { resolve } from 'node:path'; +import { setTimeout as pause } from 'node:timers/promises'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; + +for (const name of ['MCB_AGENT_TOKEN','MCB_PLAYER_ID','MCB_PROJECT_ID']) if (!process.env[name]) throw new Error(`${name} is required`); +const transport = new StdioClientTransport({ command: process.execPath, args: [resolve('dist/mcp.js')], env: Object.fromEntries(Object.entries(process.env).filter(([key,value]) => value !== undefined && key !== 'MCB_TOKEN')), stderr: 'pipe' }); +const client = new Client({ name: 'minecraft-builder-live-smoke', version: '0.1.0' }); +async function tool(name, args = {}, allowError = false) { + for (let attempt = 0; attempt < 100; attempt++) { + const result = await client.callTool({ name, arguments: args }); + const data = JSON.parse(result.content.find(item => item.type === 'text').text); + if (result.isError && data.code === 'busy') { await pause(50); continue; } + if (result.isError && !allowError) throw new Error(`${name}: ${data.code}: ${data.message}`); + return { data, isError: result.isError === true }; + } + throw new Error(`${name}: backend remained busy`); +} +async function finished(operation_id) { + for (let attempt=0; attempt<400; attempt++) { + const {data} = await tool('operation_status',{operation_id}); + if (['applied','conflict','failed','cancelled','recovery_required'].includes(data.status)) return data; + await pause(50); + } + throw new Error(`Operation did not finish within 20 seconds: ${operation_id}`); +} +const min = {x:Number(process.env.MCB_TEST_X ?? 0), y:Number(process.env.MCB_TEST_Y ?? 96), z:Number(process.env.MCB_TEST_Z ?? 0)}; +const max = {x:min.x+2,y:min.y+2,z:min.z+2}; +let operation; +try { + await client.connect(transport); + const {data:context} = await tool('project_context'); + console.log(JSON.stringify({step:'context',project_id:context.project_id,world_id:context.world_id,capabilities:context.capabilities})); + const {data:before} = await tool('region_inspect',{min,max,detail:'blocks'}); + assert.equal(before.blocks.length,27); assert.ok(before.blocks.every(block=>block.state==='minecraft:air'),'Test area must be entirely air; choose MCB_TEST_X/Y/Z in the configured project region.'); + const {data:plan} = await tool('build_prepare',{recipe:{version:1,operations:[{type:'box',min,max,block:'minecraft:stone_bricks',hollow:true}]}}); + assert.equal(plan.changed_blocks,26); + const apply = {plan_id:plan.plan_id,plan_hash:plan.plan_hash,idempotency_key:`live-smoke-${randomUUID()}`}; + const {data:started} = await tool('build_apply',apply); operation=started.operation_id; + const done = await finished(operation); assert.equal(done.status,'applied'); assert.equal(done.written,26); + const {data:replayed} = await tool('build_apply',apply); assert.equal(replayed.operation_id,operation); + const {data:after} = await tool('region_inspect',{min,max,detail:'blocks'}); + assert.equal(after.blocks.filter(block=>block.state==='minecraft:stone_bricks').length,26); + assert.equal(after.blocks.filter(block=>block.state==='minecraft:air').length,1); + console.log(JSON.stringify({step:'build-and-idempotency',operation_id:operation,status:done.status,written:done.written})); + const {data:exported}=await tool('schematic_export',{name:'Live MCP hollow box',min,max,origin:min}); + assert.match(exported.assetId,/^[A-Za-z0-9][A-Za-z0-9_-]{0,63}$/); + const {data:library}=await tool('asset_list',{query:'Live MCP hollow box'}); + assert.ok(library.assets.some(asset=>asset.assetId===exported.assetId),'Exported schematic must be listed by asset ID'); + console.log(JSON.stringify({step:'schematic-export',asset_id:exported.assetId,width:exported.width,height:exported.height,length:exported.length})); + const {data:undo} = await tool('operation_undo_prepare',{operation_id:operation}); + const {data:undoStarted} = await tool('build_apply',{plan_id:undo.plan_id,plan_hash:undo.plan_hash,idempotency_key:`live-undo-${randomUUID()}`}); + const undoDone=await finished(undoStarted.operation_id); assert.equal(undoDone.status,'applied'); + const {data:restored}=await tool('region_inspect',{min,max,detail:'blocks'});assert.ok(restored.blocks.every(block=>block.state==='minecraft:air')); + console.log(JSON.stringify({step:'checked-undo',operation_id:undoStarted.operation_id,status:undoDone.status,restored_air_blocks:27})); + const {data:importPlan}=await tool('schematic_import_prepare',{asset_id:exported.assetId,target:min,rotation:0}); + assert.equal(importPlan.changed_blocks,26); + const {data:importStarted}=await tool('build_apply',{plan_id:importPlan.plan_id,plan_hash:importPlan.plan_hash,idempotency_key:`live-import-${randomUUID()}`}); + operation=importStarted.operation_id; + const imported=await finished(operation);assert.equal(imported.status,'applied');assert.equal(imported.written,26); + const {data:importedBlocks}=await tool('region_inspect',{min,max,detail:'blocks'}); + assert.equal(importedBlocks.blocks.length,27); + assert.equal(importedBlocks.blocks.filter(block=>block.state==='minecraft:stone_bricks').length,26); + assert.equal(importedBlocks.blocks.filter(block=>block.state==='minecraft:air').length,1); + const {data:importUndo}=await tool('operation_undo_prepare',{operation_id:operation}); + const {data:importUndoStarted}=await tool('build_apply',{plan_id:importUndo.plan_id,plan_hash:importUndo.plan_hash,idempotency_key:`live-import-undo-${randomUUID()}`}); + const importUndoDone=await finished(importUndoStarted.operation_id);assert.equal(importUndoDone.status,'applied'); + const {data:importRestored}=await tool('region_inspect',{min,max,detail:'blocks'}); + assert.equal(importRestored.blocks.length,27);assert.ok(importRestored.blocks.every(block=>block.state==='minecraft:air')); + console.log(JSON.stringify({step:'schematic-roundtrip-and-undo',asset_id:exported.assetId,import_operation_id:operation,undo_operation_id:importUndoStarted.operation_id,written:imported.written,restored_air_blocks:27})); + operation=undefined; + const cameras = await tool('camera_list'); + if(!cameras.data.configured){const unavailable=await tool('camera_capture',{pose:{x:min.x,y:min.y,z:min.z,yaw:0,pitch:0}},true);assert.equal(unavailable.isError,true);assert.equal(unavailable.data.code,'camera_unavailable');console.log(JSON.stringify({step:'camera',status:'honestly-unavailable'}));} + const bounds=context.region; + const outside={x:bounds.max.x+1,y:bounds.min.y,z:bounds.min.z}; + const denied=await tool('region_inspect',{min:outside,max:outside},true);assert.equal(denied.isError,true);assert.equal(denied.data.code,'out_of_bounds'); + console.log(JSON.stringify({step:'scope-boundary',status:'rejected',code:denied.data.code})); + console.log('LIVE MCP SMOKE PASSED'); +} catch(error) { + if(operation) console.error(`Inspect operation ${operation}; test failure can leave blocks. No blind cleanup was attempted.`); + throw error; +} finally { await client.close(); } diff --git a/bridge/test/mcp.test.mjs b/bridge/test/mcp.test.mjs new file mode 100644 index 0000000..6dec7e7 --- /dev/null +++ b/bridge/test/mcp.test.mjs @@ -0,0 +1,44 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { createServer } from 'node:http'; +import { once } from 'node:events'; +import { resolve } from 'node:path'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; +import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js'; +import { toolResult } from '../dist/tools.js'; + +test('real stdio MCP lists tools, validates recipes, calls backend and returns conflicts', async t => { + const seen=[]; + const backend=createServer(async(req,res)=>{ + let body='';for await(const chunk of req) body+=chunk; + const rpc=JSON.parse(body);seen.push(rpc); + res.setHeader('content-type','application/json'); + res.end(JSON.stringify(rpc.method==='build_apply'?{ok:false,error:{code:'conflict',message:'Manual block changed.'}}:{ok:true,result:{project_id:'project',capabilities:['box']}})); + });backend.listen(0,'127.0.0.1');await once(backend,'listening'); + const transport=new StdioClientTransport({command:process.execPath,args:[resolve('dist/mcp.js')],env:{MCB_BACKEND_URL:`http://127.0.0.1:${backend.address().port}`,MCB_AGENT_TOKEN:'agent',MCB_PROJECT_ID:'project',MCB_PLAYER_ID:'player'},stderr:'pipe'}); + const client=new Client({name:'test',version:'1'}); + t.after(async()=>{await client.close();backend.closeAllConnections();backend.close();}); + await client.connect(transport); + const list=await client.listTools();assert.equal(list.tools.length,14); + assert.ok(list.tools.some(tool=>tool.name==='schematic_import_prepare'));assert.ok(!list.tools.some(tool=>tool.name==='chat_poll')); + const context=await client.callTool({name:'project_context',arguments:{player_id:'forged'}}); + assert.equal(JSON.parse(context.content[0].text).project_id,'project'); + assert.equal(seen[0].params.player_id,'player'); + const invalid=await client.callTool({name:'build_prepare',arguments:{recipe:{version:1,operations:[{type:'execute',code:'bad'}]}}}); + assert.equal(invalid.isError,true);assert.equal(seen.length,1); + const prepared=await client.callTool({name:'build_prepare',arguments:{recipe:{version:1,operations:[{type:'box',min:{x:0,y:64,z:0},max:{x:2,y:66,z:2},block:'minecraft:stone'}]}}}); + assert.ok(!prepared.isError);assert.equal(seen[1].method,'build_prepare'); + const conflict=await client.callTool({name:'build_apply',arguments:{plan_id:'plan',plan_hash:'hash',idempotency_key:'key'}}); + assert.equal(conflict.isError,true);assert.equal(JSON.parse(conflict.content[0].text).code,'conflict'); + const beforeAssets=seen.length; + const invalidPath=await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'../../secret',target:{x:0,y:64,z:0}}});assert.equal(invalidPath.isError,true); + const invalidRotation=await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'asset',target:{x:0,y:64,z:0},rotation:45}});assert.equal(invalidRotation.isError,true);assert.equal(seen.length,beforeAssets); + await client.callTool({name:'schematic_import_prepare',arguments:{asset_id:'asset',target:{x:0,y:64,z:0},rotation:90}});assert.equal(seen.at(-1).params.rotation,90); + await client.callTool({name:'asset_list',arguments:{query:'tower'}});assert.equal(seen.at(-1).params.query,'tower'); +}); +test('camera image is a native MCP image rather than a text context dump',()=>{ + const result=toolResult({status:'completed',captureId:'c1',imageBase64:'YWJj',mimeType:'image/png'}); + assert.equal(result.content[1].type,'image');assert.ok(!result.content[0].text.includes('YWJj')); + assert.throws(()=>toolResult({status:'pending',imageBase64:'YWJj',mimeType:'image/png'}),/invalid/); + assert.throws(()=>toolResult({blocks:'a'.repeat(70000)}),/too large/); +}); diff --git a/bridge/test/security.test.mjs b/bridge/test/security.test.mjs new file mode 100644 index 0000000..b77da55 --- /dev/null +++ b/bridge/test/security.test.mjs @@ -0,0 +1,21 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {mkdtemp,readFile,writeFile,rm} from 'node:fs/promises'; +import {tmpdir} from 'node:os'; +import {join} from 'node:path'; +import {codexPaths,prepareCodexHome,CODEX_CONFIG_TOML,securityReport} from '../dist/security.js'; +test('dedicated home gets known config and never overwrites existing user configuration',async t=>{ + const root=await mkdtemp(join(tmpdir(),'mcb-security-'));t.after(()=>rm(root,{recursive:true,force:true})); + const paths=codexPaths(root);await prepareCodexHome(paths); + assert.equal(await readFile(join(paths.codexHome,'config.toml'),'utf8'),CODEX_CONFIG_TOML); + await prepareCodexHome(paths); + await writeFile(join(paths.codexHome,'config.toml'),'sandbox_mode = "danger-full-access"\n'); + await assert.rejects(prepareCodexHome(paths),/will not be overwritten/); + assert.equal(await readFile(join(paths.codexHome,'config.toml'),'utf8'),'sandbox_mode = "danger-full-access"\n'); +}); +test('doctor security report describes upstream workspace-write limitation honestly',()=>{ + const report=securityReport(codexPaths('/state')); + assert.equal(report.configuredSandbox,'read-only');assert.equal(report.adapterTurnSandbox,'workspace-write');assert.equal(report.runtimeVerified,false); + assert.equal(report.pinnedCliFeatureProbe.unified_exec,true); + assert.ok(report.limitations.some(text=>text.includes('temporary paths'))); +}); diff --git a/bridge/tsconfig.json b/bridge/tsconfig.json new file mode 100644 index 0000000..be21c1f --- /dev/null +++ b/bridge/tsconfig.json @@ -0,0 +1,8 @@ +{ + "compilerOptions": { + "target": "ES2022", "module": "NodeNext", "moduleResolution": "NodeNext", + "types": ["node"], "strict": true, "noUncheckedIndexedAccess": true, "outDir": "dist", "rootDir": "src", + "declaration": true, "sourceMap": true, "skipLibCheck": true + }, + "include": ["src/**/*.ts"] +} diff --git a/camera-mod/.gitignore b/camera-mod/.gitignore new file mode 100644 index 0000000..7bb9f0a --- /dev/null +++ b/camera-mod/.gitignore @@ -0,0 +1,5 @@ +.gradle/ +build/ +run/ +.idea/ +*.iml diff --git a/camera-mod/README.md b/camera-mod/README.md new file mode 100644 index 0000000..199e05b --- /dev/null +++ b/camera-mod/README.md @@ -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 `. JSON не записывается в лог. + +- `GET /health` — кэш состояния последнего клиентского тика: `status`, `connected`, `spectator`, `busy`, `dimension`, `playerId`, `updatedAt`. Старое `updatedAt` означает, что клиент перестал обновляться. +- `POST /v1/capture` — поставить один снимок в работу. Ответ HTTP 202: `{"status":"pending","captureId":""}`. При занятой камере HTTP 409 и `camera_busy`. +- `GET /v1/captures/` — получить `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`**: подтверждения обработки конкретной серверной ревизии ещё нет. Отдельно остаются проверки восстановления всех настроек вида, таймаута при свёрнутом окне, отключения посреди снимка, сторонних шейдеров и отдельного аккаунта камеры. Рабочий графический цикл подтверждён для описанного сценария с одним клиентом. diff --git a/camera-mod/build.gradle b/camera-mod/build.gradle new file mode 100644 index 0000000..dfb9a24 --- /dev/null +++ b/camera-mod/build.gradle @@ -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 diff --git a/camera-mod/gradle.properties b/camera-mod/gradle.properties new file mode 100644 index 0000000..3fecc33 --- /dev/null +++ b/camera-mod/gradle.properties @@ -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 diff --git a/camera-mod/gradle/wrapper/gradle-wrapper.jar b/camera-mod/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..b1b8ef5 Binary files /dev/null and b/camera-mod/gradle/wrapper/gradle-wrapper.jar differ diff --git a/camera-mod/gradle/wrapper/gradle-wrapper.properties b/camera-mod/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..62221af --- /dev/null +++ b/camera-mod/gradle/wrapper/gradle-wrapper.properties @@ -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 diff --git a/camera-mod/gradlew b/camera-mod/gradlew new file mode 100755 index 0000000..04c0e4e --- /dev/null +++ b/camera-mod/gradlew @@ -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" "$@" diff --git a/camera-mod/gradlew.bat b/camera-mod/gradlew.bat new file mode 100644 index 0000000..62ce92d --- /dev/null +++ b/camera-mod/gradlew.bat @@ -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% diff --git a/camera-mod/settings.gradle b/camera-mod/settings.gradle new file mode 100644 index 0000000..e2eb80c --- /dev/null +++ b/camera-mod/settings.gradle @@ -0,0 +1,8 @@ +pluginManagement { + repositories { + maven { url = 'https://maven.fabricmc.net/' } + mavenCentral() + gradlePluginPortal() + } +} +rootProject.name = 'minecraft-builder-camera' diff --git a/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraClient.java b/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraClient.java new file mode 100644 index 0000000..326f72b --- /dev/null +++ b/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraClient.java @@ -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 active = new AtomicReference<>(); + private final LinkedHashMap 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)); } + } +} diff --git a/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraHttpServer.java b/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraHttpServer.java new file mode 100644 index 0000000..5af2732 --- /dev/null +++ b/camera-mod/src/client/java/dev/minecraftbuilder/camera/CameraHttpServer.java @@ -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(); } +} diff --git a/camera-mod/src/client/java/dev/minecraftbuilder/camera/CaptureRequest.java b/camera-mod/src/client/java/dev/minecraftbuilder/camera/CaptureRequest.java new file mode 100644 index 0000000..da70709 --- /dev/null +++ b/camera-mod/src/client/java/dev/minecraftbuilder/camera/CaptureRequest.java @@ -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 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; + } +} diff --git a/camera-mod/src/client/java/dev/minecraftbuilder/camera/mixin/GameRendererMixin.java b/camera-mod/src/client/java/dev/minecraftbuilder/camera/mixin/GameRendererMixin.java new file mode 100644 index 0000000..16fe8e9 --- /dev/null +++ b/camera-mod/src/client/java/dev/minecraftbuilder/camera/mixin/GameRendererMixin.java @@ -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); + } +} diff --git a/camera-mod/src/main/resources/fabric.mod.json b/camera-mod/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..2359115 --- /dev/null +++ b/camera-mod/src/main/resources/fabric.mod.json @@ -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" + } +} diff --git a/camera-mod/src/main/resources/minecraft-builder-camera.mixins.json b/camera-mod/src/main/resources/minecraft-builder-camera.mixins.json new file mode 100644 index 0000000..c3bc2b0 --- /dev/null +++ b/camera-mod/src/main/resources/minecraft-builder-camera.mixins.json @@ -0,0 +1,7 @@ +{ + "required": true, + "package": "dev.minecraftbuilder.camera.mixin", + "compatibilityLevel": "JAVA_25", + "client": ["GameRendererMixin"], + "injectors": { "defaultRequire": 1 } +} diff --git a/camera-mod/src/test/java/dev/minecraftbuilder/camera/CameraHttpServerTest.java b/camera-mod/src/test/java/dev/minecraftbuilder/camera/CameraHttpServerTest.java new file mode 100644 index 0000000..2a6f56d --- /dev/null +++ b/camera-mod/src/test/java/dev/minecraftbuilder/camera/CameraHttpServerTest.java @@ -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 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()); + } +} diff --git a/camera-mod/src/test/java/dev/minecraftbuilder/camera/CaptureRequestTest.java b/camera-mod/src/test/java/dev/minecraftbuilder/camera/CaptureRequestTest.java new file mode 100644 index 0000000..fb0a3a0 --- /dev/null +++ b/camera-mod/src/test/java/dev/minecraftbuilder/camera/CaptureRequestTest.java @@ -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()); } +} diff --git a/docs/DESIGN.md b/docs/DESIGN.md new file mode 100644 index 0000000..4acaff2 --- /dev/null +++ b/docs/DESIGN.md @@ -0,0 +1,407 @@ +# minecraft-builder-mcp — дизайн-документ + +Версия документа: 0.1 · Дата: 12 сентября 2026 года + +Статус: целевой дизайн. Первый прототип создан; фактические возможности, проверки и отличия от этого документа перечислены в [IMPLEMENTATION.md](IMPLEMENTATION.md). Разделы ниже описывают также ещё не реализованные требования. + +## 1. Назначение + +Создать строительную среду для Minecraft Java Edition, в которой человек и ИИ-агент совместно проектируют, строят и редактируют карты. Пользователь общается с Codex в игровом чате или во внешнем клиенте. Агент получает структурированные сведения о мире, применяет массовые изменения через MCP, смотрит реальные снимки и исправляет результат. + +Основной сценарий: «Построй башню здесь» → обследование участка → строительство → снимки → уточнение пропорций. Пользователь может параллельно строить вручную, а затем попросить: «Сохрани мои окна, добавь два этажа и переделай крышу». + +Результат — обычные ванильные блоки. Карта должна оставаться пригодной к использованию после удаления наших компонентов. История, рецепты и названия частей хранятся отдельно от игровых блоков. + +## 2. Решения и рабочие предположения + +Из обсуждения следуют требования: Paper как строительная среда с перспективой мини-игр; Codex через готовый `codex-acp`; Minecraft MCP для работы с миром; виртуальные камеры; массовые операции; именованные части; сохранение ручных правок; экономное использование контекста; отмена и перенос построек. + +Для этого документа приняты следующие проектные решения, которые можно изменить до реализации: + +- Первая версия рассчитана на одного владельца и небольшой круг доверенных строителей, один Paper-сервер и одну активную операцию записи на строительную область. +- Плагин на сервере является единственным компонентом нашей системы, который непосредственно изменяет мир. +- Камеры обслуживает отдельный клиент Minecraft с Fabric-модом. Обычному игроку клиентский мод для чата не требуется. +- Bridge написан на TypeScript; Paper-плагин и Fabric-мод — на Java. Точные версии инструментов фиксируются после проверки совместимости. +- Строительный язык первой версии — ограниченное декларативное описание геометрии и повторений. Произвольный Python/JavaScript внутри сервера не выполняется. +- Снимки, конфликты и история не имеют собственной модели внутри MCP. Их интерпретирует агент с поддержкой изображений. +- Пользовательская команда на строительство разрешает обычные изменения внутри выбранной области; подтверждение каждого пакета блоков не требуется. Настоящие конфликты и выход за полномочия обрабатываются отдельно. + +## 3. Почему нужны клиентская и серверная части + +Клиентский мод может управлять камерой, снимать изображение, читать присланные клиенту чанки и отправлять команды, разрешённые игроку. Этого достаточно для прототипа, который строит через серверные команды. Но такой клиент не является источником окончательного состояния мира и не обеспечивает согласованную проверку и запись блоков на сервере. + +Серверный компонент нужен для достоверного чтения участка, проверки прав, сравнения состояний непосредственно перед записью, журналирования и применения изменений с ограничением нагрузки. Он не умеет сам отрисовывать игровой вид. Клиентское и серверное исполнение в Minecraft разделены; рендеринг выполняет клиент. [Разделение сторон в Fabric](https://wiki.fabricmc.net/tutorial%3Aside). + +Целевая установка может целиком работать на одном компьютере: Paper, Bridge, Codex и клиент камеры. Выделенная машина и аренда хостинга не обязательны. Отдельный наблюдатель потребует собственной допустимой игровой сессии; нельзя предполагать, что одна учётная запись позволит одновременно держать игрока и камеру на одном сервере. При отсутствии второй сессии возможен режим камеры в клиенте пользователя с временным переключением вида; это отдельный компромисс интерфейса. + +Одиночная игра содержит встроенный сервер. В дальнейшем можно добавить Fabric-модуль для его серверной стороны, сохранив MCP-контракты. Это устраняет отдельный процесс Paper, но требует другого адаптера мира. Одного мода, исполняющегося только на логической клиентской стороне, для полных гарантий недостаточно. + +## 4. Платформа и совместимость + +Кандидат для первого прототипа — Minecraft/Paper 26.2 и Java 25. На дату документа страница загрузки предлагает Paper 26.2, а документация указывает Java 25 для веток 26.1+. Это подтверждает наличие платформы, но не совместимость всех наших зависимостей. [Загрузка Paper](https://papermc.io/downloads/paper), [требования Java](https://docs.papermc.io/paper/getting-started/). + +До реализации основной функциональности необходимо зафиксировать точные версии Paper, WorldEdit, Fabric Loader/API, Codex, `codex-acp`, MCP/ACP SDK и Node.js. Обновление зависимостей не должно происходить автоматически при каждом запуске. Версии и хеши сборок войдут в будущий файл совместимости. + +WorldEdit используется для выделений и формата `.schem`; возможность использовать его как механизм записи проверяется отдельно. Его `EditSession` поддерживает пакетирование и историю, однако это не заменяет нашу проверку конфликтов, постоянный журнал и управление временем исполнения. Буферизация не должна переносить фактическую запись за пределы проверенного серверного шага. [WorldEdit Edit Sessions](https://worldedit.enginehub.org/en/latest/api/concepts/edit-sessions/). + +Начальный гарантируемый набор — ванильные строительные блоки без инвентарей и пользовательского NBT, включая протестированные состояния брёвен, ступеней и плит. Допускается воздух как результат удаления. Двери и другие составные конструкции подключаются только после реализации неделимых групп изменений. Гравитационные блоки, жидкости, редстоун, сущности и block entities не входят в первоначальную гарантию редактирования и отмены. + +Ограничение действует и на исходное содержимое: операция не может молча затереть сундук или другой неподдерживаемый блок. Предварительная проверка обнаруживает это до применения и возвращает понятную причину. + +## 5. Границы первой версии + +В v0.1 входят: + +- Команды чата, отдельная сессия проекта, поток коротких сообщений о ходе работы и остановка. +- Выделенная строительная область и локальный осмотр мира. +- Компактные описания форм, повторения, палитры и воспроизводимый `seed`. +- Предварительный план, подсчёт изменений и применение порциями. +- Именованные части с точными наборами принадлежащих им блоков. +- Проверка изменений после чтения, остановка на конфликте и отмена с проверками. +- Постоянный журнал операций и обнаружение незавершённой записи после перезапуска. +- Одна обслуживающая камера, несколько сохранённых ракурсов и выдача изображений через MCP. +- Импорт и экспорт `.schem` в пределах поддерживаемого набора данных. + +За пределами v0.1: публичный сервис для любых игроков, несколько одновременно пишущих агентов в одной области, Folia, полноценные мини-игры, произвольный доступ к серверной консоли, генерация 3D сторонними сервисами, автоматическая вокселизация мешей, универсальная физическая симуляция, произвольные скрипты с доступом к ОС и интеллектуальное перенесение любой ручной правки при смене геометрии. + +Система помогает строить карты для мини-игр, но не реализует правила самих мини-игр. Автоматическая оценка красоты не является гарантией качества. + +## 6. Компоненты и связи + +Путь запроса: игровой чат → Paper-плагин → Bridge как ACP-клиент → `codex-acp` → Codex. Путь изменения: Codex → Minecraft MCP в Bridge → Paper-плагин → мир. Путь изображения: Codex → Minecraft MCP → Camera Worker → Fabric-клиент → изображение. + +### Paper-плагин + +Отвечает за команды, личности игроков, области, полномочия, снимки состояния блоков, валидацию планов, расписание применения и постоянную историю. Плагин проверяет ограничения независимо от того, что обещали агент и Bridge. + +### Bridge + +Запускает закреплённую версию `codex-acp`, реализует ACP-клиент и предоставляет инструменты Minecraft MCP. Хранит связь проекта с диалогом, форматирует сообщения для игрового чата и ограничивает объём данных, передаваемых модели. Не становится альтернативным источником истины о блоках. + +`codex-acp` уже реализует преобразование ACP в операции Codex App Server, поддерживает изображения и подключение MCP-серверов. Поэтому отдельный ACP-адаптер Codex в проекте не пишется. Возможности конкретной закреплённой версии проверяются при установлении соединения. [Репозиторий codex-acp](https://github.com/agentclientprotocol/codex-acp). + +### Camera Worker + +Управляет очередью снимков и подключённым Fabric-клиентом. Хранит ракурсы, проверяет загрузку сцены и возвращает изображения с метаданными. Отказ камеры не уничтожает историю и не мешает чтению блоков; задача явно получает статус «визуально не проверено». + +### Хранилища + +Плагин хранит SQLite для метаданных и индексирования, а крупные снимки и изменения — в сжатых файлах с контрольными суммами. Он единственный писатель своей базы. Bridge отдельно хранит ACP-сессии и компактные сводки; Camera Worker — изображения. Общая база, которую одновременно напрямую меняют Java и Node.js, не используется. + +Локальные соединения по умолчанию привязаны к loopback и защищены отдельными секретами компонентов. Для удалённой установки предполагается SSH-туннель или проверенное защищённое соединение; публичный доступ к интерфейсу изменения мира не нужен. + +## 7. Сессии и игровой интерфейс + +Основные команды, проектируемые для v0.1: + +- `/ai <текст>` — сообщение агенту в активном проекте. +- `/ai project create <имя>` и `/ai project use <имя>` — создание и выбор проекта. +- `/ai area set` — зафиксировать выбранный участок после проверки размеров и прав. +- `/ai status` — состояние текущего запроса, операции и камеры. +- `/ai stop` — остановить агентский ход и запросить остановку активного изменения мира. +- `/ai undo ` — подготовить и применить проверяемую отмену собственной операции. +- `/ai camera save <имя>` — сохранить положение и направление взгляда. +- `/ai protect ` — защитить часть от изменений агента. + +Обычный общий чат не отправляется модели целиком. Сообщения `/ai` и явные упоминания передаются только после проверки отправителя. Ответ по умолчанию виден инициатору; общий строительный канал можно добавить настройкой. + +На проект назначается очередь запросов. В v0.1 один активный ход агента на проект; новые сообщения очередятся, а изменение задания во время работы требует согласованного прерывания. UUID игрока, UUID мира и идентификатор проекта берутся с сервера. Модель не может подменить их текстом запроса. + +Bridge хранит идентификатор ACP-сессии, но проект не зависит от вечной доступности этого диалога. При невозможности возобновления создаётся новая сессия и передаются сводка проекта, текущая операция и ссылки на данные. Подключение к текущему диалогу настольного Codex автоматически не предполагается. Жизненный цикл сверяется с [ACP Session Setup](https://agentclientprotocol.com/protocol/v1/session-setup) и [ACP Prompt Turn](https://agentclientprotocol.com/protocol/v1/prompt-turn). + +Чат показывает этапы и результат, а не каждую установку блока. Сообщения ограничены по длине и частоте. В сообщении о конфликте пользователь видит часть здания, место и последствия выбора; технические идентификаторы доступны в деталях. + +## 8. Модель данных + +**Project:** стабильный ID, владелец, участники, world UUID, world epoch, разрешённые области, политика блоков, настройки качества и краткая сводка замысла. Epoch меняется при восстановлении или замене мира, чтобы старые планы нельзя было применить к другой копии. + +**Region:** измерение, включительные целочисленные границы `min/max`, ограничения чтения и записи. Координаты блоков хранятся целыми; камеры — вещественными. Высота и граница мира берутся из сервера. Локальные координаты рецептов имеют явно заданную точку привязки и преобразование в координаты мира. + +**Part:** ID, имя, родитель, теги, точная маска блоков, ограничивающий объём, точка привязки, ревизия, режим защиты, ссылка на рецепт. Ограничивающий прямоугольник нужен для поиска и не означает владения всем его содержимым. В v0.1 редактируемые дочерние маски не пересекаются; родитель объединяет их. + +**Recipe:** версия языка, версия генератора, параметры, палитра, seed, подчасти и преобразования. Одинаковые входы и версии должны порождать одинаковый план. Повороты преобразуют и координаты, и направленные состояния блоков. Неподдерживаемые преобразования отклоняются. + +**Snapshot:** ID, мир и epoch, маска, канонические состояния блоков, ревизии секций и хеши. Снимок, собранный за несколько тиков, не объявляется глобально атомарным: изменившиеся во время сборки секции перечитываются или снимок помечается нестабильным. + +**Plan:** неизменяемый ID и хеш содержимого, инициатор, область, базовый снимок, read set зависимостей, write set с `expected/desired`, группы взаимосвязанных блоков, срок действия и статистика. Read set включает опоры и свободные проходы, если от них зависит решение. План хранится на сервере; модели возвращается сводка. + +**Operation:** ID, idempotency key, plan ID, состояние, номера порций, число подтверждённых записей, конфликты, автор, временные метки и связь с операцией отмены. Точное исходное и полученное содержимое хранится в постоянном журнале. + +**Camera:** имя, мир, позиция, yaw/pitch, FOV, разрешение, профиль отображения. **Capture:** ID изображения, camera ID, время, связанная операция, сведения о загрузке и статус свежести. Снимок относится к моменту наблюдения, а не является атомарным изображением состояния всего сервера. + +## 9. Строительный язык и рабочий цикл + +В v0.1 агент передаёт JSON-программу: параметры, палитру и последовательность операций `box`, `line`, `cylinder`, `arch`, `repeat`, `transform`, `replace` и `paste`. Это проектируемые примитивы, а не существующие инструменты. Для `replace` обязательны маска области и фильтр исходных состояний. + +Повторения ограничены счётчиком; разрешены только определённые числовые выражения и ссылки на параметры. Нет `eval`, бесконечных циклов, загрузки модулей, сети и файловых путей. Исполнитель имеет лимиты глубины, операций, памяти, времени и итогового числа блоков. Поддержка произвольного кода в будущем требует отдельного изолированного процесса с ограничениями ОС, а не запрета нескольких строк в скрипте. + +Последовательность работы: + +1. Агент узнаёт возможности сервера, активный проект, участок и ракурсы. +2. Запрашивает сводку рельефа и существующих частей; при необходимости — локальные блоки и снимок. +3. Формирует рецепт или точечную правку конкретной части. +4. Плагин создаёт снимок зависимостей, рассчитывает план и проверяет ограничения без записи в мир. +5. Агент получает объём, материалы, пересечения и предупреждения о непроверенных свойствах. +6. Допустимый план применяется порциями. Для обычной разрешённой постройки повторное подтверждение не требуется. +7. После применения выполняются структурные проверки и снимки выбранных ракурсов. +8. Исправление создаёт новую операцию. Число самостоятельных повторов ограничено; при отсутствии улучшения агент сообщает, что не удалось решить. + +Рецепт не является единственным источником текущей геометрии. После ручного изменения агент обязан опираться на актуальный мир. В v0.1 нельзя просто повторно сгенерировать целую часть поверх текущего содержимого. + +Качество постройки задаётся кратким замыслом проекта: назначение, масштаб относительно игрока, силуэт, палитра, основные материалы, входы, внутренние помещения и опорные ракурсы. Для крупной задачи агент сначала делает план объёмов, затем конструкцию, потом детали. Эти этапы остаются отдельными операциями, чтобы удачный силуэт не терялся при неудачной детализации. Пустой интерьер считается допустимым только тогда, когда он соответствует заданию. + +Структурная проверка v0.1 подтверждает заявленные размеры, границы, состояния блоков и фактическое завершение плана. Смысловые свойства вроде удобства навигации, баланса арены и красоты фасада оцениваются отдельно и не выдаются за результат простого сравнения блоков. + +## 10. Ручные изменения и конфликты + +Сравнение выполняет серверный код. Модель не получает полный список блоков для самостоятельного вычисления разницы. + +Для обычной записи используются базовое состояние `B`, текущее `C` и желаемое `D`: + +- `C = B`: запись `D` допустима, если неизменны зависимости и соблюдены права. +- `C = D`: блок уже соответствует результату; запись не требуется и не включается в новую историю как наша работа. +- Иначе: конфликт. Состояние не перезаписывается автоматически. + +В v0.1 при обнаружении ручного расхождения внутри перестраиваемой части автоматическая повторная генерация этой части останавливается. Агент может подготовить локальный план, который явно сохраняет текущую геометрию, либо строить другую, незатронутую часть. + +Для v0.2 предлагается трёхстороннее объединение: сравниваются предыдущий сгенерированный результат `G0`, текущий мир `C` и новый результат `G1`. Если `G1 = G0`, ручная правка сохраняется; если `C = G0`, можно принять новую геометрию; если `C = G1`, запись не нужна; остальные пересечения требуют решения. Это объединение по координатам, а не понимание смысла окна или лестницы. + +Пример: ручное окно в неизменяемой стене сохраняется при добавлении верхних этажей. Если новый этаж сдвигает всю стену, система не знает автоматически, куда перенести окно. В v0.1 такая перестройка останавливается и предлагается локальный новый план. Автоматическое перенесение поправок в координатах рецепта относится к следующей версии. + +При заранее найденном конфликте план не начинает запись. Если конфликт возник во время выполнения, операция останавливается перед следующей затронутой группой и возвращает частичный результат. Она не продолжает молча строить остальные фрагменты: это может оставить конструкцию геометрически неверной. + +Варианты разрешения: сохранить текущий мир и перепланировать; исключить защищённую часть; по явному решению владельца заменить конкретный конфликтующий фрагмент. Новый выбор создаёт новый план от свежего состояния. Глобального режима «игнорировать все конфликты» в инструментах агента нет. + +События игроков, природных изменений и интеграция WorldEdit ускоряют обновление ревизий. Однако не все сторонние плагины обязаны вызывать одинаковые события. Поэтому хеши и ревизии служат ускорением, а проверка живых блоков непосредственно перед записью остаётся обязательной. Неизвестный источник изменения называется «внешнее изменение», а не приписывается игроку. + +Серверная проверка защищает текущее содержимое. Если неподконтрольный плагин изменил блок и вернул его обратно между проверками, одно сравнение содержимого не восстановит эту историю. Полная авторская история всех возможных изменений мира не обещается. + +## 11. Применение, остановка и восстановление + +Состояния операции: `prepared` → `queued` → `applying` → `applied`. Ветви завершения: `conflict`, `cancelled`, `failed`, `recovery_required`. Каждое состояние сопровождается количеством реально подтверждённых блоков: даже `cancelled` может означать частично изменённый мир. Визуальная проверка имеет отдельный статус `pending/passed/needs_changes/unavailable`; снимок не определяет завершённость записи. + +Подготовка геометрии, сжатие, работа с файлами, сеть и запросы к модели выполняются вне игрового потока. Чтение и изменение живого мира выполняются через допустимые серверные API в серверном потоке. Такой подход соответствует ограничениям [Paper Scheduler](https://docs.papermc.io/paper/dev/scheduler/). + +Алгоритм порции: + +1. Сформировать ограниченную группу изменений и зависимости от её окружения. Общий диспетчер плагина сериализует пересекающиеся операции по UUID мира и области, в том числе между разными проектами. Блокировка одного проекта не считается достаточной защитой. +2. В серверном потоке прочитать фактическое исходное состояние и подготовить запись намерения с `before/after`, ID группы и контрольной суммой. +3. Сохранить намерение в постоянный журнал вне серверного потока и дождаться подтверждения сохранения. До этого мир не меняется. +4. Вернуться в серверный поток; повторно проверить полномочия, epoch, ограничения, ожидаемые состояния и зависимости. При изменениях остановиться до записи этой группы. +5. В том же серверном шаге без уступки управления применить допустимую небольшую группу, проверить результат и зарегистрировать фактически изменённые блоки. Запись намерения со статусом пропуска или ошибки также сохраняется. +6. Зафиксировать завершение группы и только затем продолжить следующую. Обновить сводку операции и инвалидировать затронутые кэши. + +План должен объявлять зависимости. Если часть зависит от ранее обработанных блоков, последующие проверки учитывают уже подтверждённые значения самой операции. Изменение важной опоры после её обработки приостанавливает дальнейшие зависимые шаги. Полная согласованность всего здания на протяжении многих тиков без блокировки всех внешних писателей не гарантируется; после записи нужна итоговая проверка. + +Обычная ручная правка не вклинивается между проверкой и записью одной серверной порции. Но даже порция не является ACID-транзакцией Minecraft: исключение, вложенные события или физика могут дать частичное изменение. Исполнитель записывает фактический результат, а операция останавливается. Для составных объектов задаются маленькие неделимые логические группы и отдельные правила проверки. Поддержка таких групп не означает атомарности при падении процесса. + +`/ai stop` имеет два независимых действия: ACP-отмена хода модели и серверный флаг отмены операции. Остановка модели сама по себе не отменяет уже запущенную запись. Плагин проверяет флаг перед каждой порцией; выполненные изменения остаются в истории. Потеря Bridge запрещает запуск новых порций после короткого таймаута соединения; выполненная порция не повторяется вслепую. + +Все изменяющие запросы имеют idempotency key, связанный с проектом и хешем содержимого. Повтор с тем же ключом и тем же планом возвращает существующую операцию; тот же ключ с другим содержимым отклоняется. Повтор после сетевого таймаута начинается с запроса состояния операции. + +После сбоя плагин обнаруживает незавершённые группы и переходит в `recovery_required`. Сохранение файлов мира и журнала не является одной общей транзакцией. Поэтому при запуске выполняется сверка живого мира с `before/after`: старое значение, новое значение либо постороннее состояние. Ни неизвестное состояние, ни неоднозначная история не перезаписываются автоматически. Возобновление или отмена строятся как новый проверенный план; запись блоков по одному лишь последнему статусу журнала запрещена. + +Отмена создаёт обратную операцию только для фактически записанных нами блоков. Она возвращает `before`, если текущее состояние совпадает с подтверждённым `after` и нет известной более поздней записи другого действия. Для известного последующего изменения возвращается конфликт, даже если итоговое значение случайно совпало. Для неизвестных внешних действий остаётся ограничение проверки по содержимому из раздела 10. + +Производные эффекты — течение воды, падение песка, изменения инвентарей, рост растений, обновления редстоуна — не восстанавливаются простым обратным списком блоков. В v0.1 такие сценарии исключены из гарантируемой строительной области. Журнал не заменяет резервную копию мира. Поддержка полноценной симуляции побочных эффектов потребует отдельного дизайна. + +Проверка учитывает поддерживаемое окружение, а не только заменяемые блоки: удаление камня под песком или рядом с водой тоже может запустить побочный эффект. Для первой версии используется контролируемая строительная область; обнаруженное неподдерживаемое окружение останавливает подготовку. Абсолютная изоляция от произвольных плагинов и физики соседнего мира не обещается. + +## 12. Экономия контекста + +Модель получает сведения, необходимые для решения. Полные снимки, списки блоков, история и вычисление разницы находятся на стороне системы. Неизменённые данные не отправляются заново без причины. + +Уровни чтения: + +1. Сводка проекта: назначение карты, палитра, области, части, активная задача, ограничения и последняя проверка. +2. Сводка участка: высоты поверхности с заданным шагом, материалы, занятые объёмы и ссылки на части. Неизвестные свойства отмечаются явно. Система не обещает автоматически распознавать здания в произвольном старом мире. +3. Изменения с курсора: количество блоков, затронутые части и ограничивающие объёмы; подробности доступны страницами. +4. Небольшой точный фрагмент: палитра состояний и сжатое представление координат либо срез. Распаковка миллионов блоков в текст не допускается. +5. Изображение нужного ракурса: сначала общий вид, затем детали проблемного участка. + +Сводка о правке формируется детерминированно: например, «34 блока изменены, 6 пересекаются с планом». Формулировка «игрок добавил окно» возможна только как вывод модели или подтверждённая метка, а не как достоверный результат простого diff. + +Курсоры изменений включают world epoch, область, позицию журнала и версию схемы. После очистки журнала, потери наблюдения или замены мира ответ — `resync_required`, а не пустой список изменений. Bridge запрашивает новую локальную сводку. Сторонние изменения, не попавшие в события, ищутся повторной проверкой выбранных секций; дельты не объявляются полным журналом всего сервера. + +Обычный ответ инструмента стремится укладываться в 2–4 тысячи токенов; точные ограничения задаются также числом элементов и байтов. При превышении возвращаются счётчик, курсор и признак усечения. Сжатые бинарные блоки и base64-изображения не вставляются в текст: снимок возвращается как изображение MCP, крупные данные остаются артефактами с ID. + +Начальный бюджет одной визуальной проверки — 2 общих ракурса, при необходимости до 4 дополнительных. Серии кадров не отправляются постоянно. Начальный лимит самостоятельных циклов исправления — 3; это настраиваемая политика, а не ограничение возможностей модели. + +Нельзя обещать фиксированную цену задачи: она зависит от модели, тарифа, истории, числа изображений и повторов. Bridge учитывает фактически доступные сведения об использовании и размеры ответов. Процент экономии относительно передачи всего мира нужно измерить на тестовых задачах. + +## 13. Виртуальные камеры + +Камера — сохранённый ракурс, а не обязательная сущность или блок в мире. Один Camera Worker последовательно обслуживает несколько ракурсов. Одновременные независимые виды не входят в v0.1. + +Для съёмки клиент наблюдателя перемещается к нужному месту, чтобы сервер прислал соответствующие чанки. Недостаточно сместить только матрицу камеры далеко от игрока: клиент может не иметь данных окружающего мира. Перемещение ограничено разрешёнными областями и измерениями; учётная запись камеры не получает права редактирования. + +Процедура съёмки: + +1. Дождаться подтверждения завершения нужной операции на сервере. +2. Установить мир, позицию, ориентацию, FOV и профиль отображения. +3. Дождаться клиентской загрузки обязательных чанков и доступных сигналов завершения перестройки геометрии, затем нескольких кадров стабилизации. +4. Снять кадр без HUD и посторонних интерфейсов, вернуть изображение и метаданные. +5. Проверить, не менялась ли наблюдаемая область во время съёмки. При обнаруженных изменениях пометить изображение как потенциально устаревшее и предложить повтор. + +Подтверждение сервера ещё не означает, что клиент уже отобразил все изменения. Точный критерий готовности рендера нужно проверить прототипом на выбранной версии Fabric. При таймауте возвращается `capture_not_ready`; старый кадр не выдаётся за новый. Проверка ревизий не гарантирует отсутствия неотслеживаемых внешних изменений. + +Начальный профиль — стандартный ресурспак, без шейдеров, одинаковые FOV и разрешение для сравнения. Время суток и погода фиксируются только явно выбранным режимом проверки; ради красивого скриншота глобальный мир самовольно не меняется. Ракурсы внутри помещений проверяются на попадание камеры в непрозрачный блок. + +Автоматический обзор предлагает вход, противоположную сторону, диагональ сверху и заданные внутренние точки. Положение рассчитывается по границам части и уточняется по препятствиям. Пользователь может сохранить свои ракурсы. Пиксельное различие снимков не является метрикой красоты: его используют только как вспомогательный сигнал. + +Клиент требует графического рендеринга. Запуск без видимого окна не означает отсутствие GPU/графического контекста и не обещается до проверки. Работа рендера в Fabric меняется между версиями, поэтому мод камеры изолируется от остальных компонентов. [Рендеринг Fabric](https://docs.fabricmc.net/develop/rendering/basic-concepts). + +## 14. Предлагаемые MCP-инструменты + +Ниже — контракт проекта, а не перечень уже реализованных функций. Каталог разделяется на чтение, подготовку и изменение. Полномочия связаны с подключением, проектом и инициатором; передача `project_id` сама по себе не даёт доступа. + +- `project_context(project_id)` — возможности, версия мира, область, части, ограничения и состояние операций. +- `region_inspect(region, detail, cursor?)` — сводка, высотная карта, срез или небольшой набор точных блоков. +- `region_changes(region, since_cursor, limit)` — дельта, полнота наблюдения и следующий курсор. +- `part_get(part_id)` — маска, параметры, защита, версия и сведения о внешних изменениях. +- `part_define(region_or_mask, name, parent_id?)` — зарегистрировать часть без изменения блоков; проверить права и пересечения. +- `build_prepare(target, recipe_or_patch, base_snapshot_id?, request_id)` — сохранить неизменяемый план; вернуть его ID, хеш, статистику и конфликты. +- `build_apply(plan_id, plan_hash, idempotency_key)` — проверить разрешение и поставить план в очередь; вернуть operation ID. +- `operation_status(operation_id, since_cursor?)` — прогресс, частичный результат, ошибки и ссылки на конфликты. +- `operation_cancel(operation_id, idempotency_key)` — остановить дальнейшее применение. +- `operation_undo_prepare(operation_id, request_id)` — создать обратный план со свежими проверками; применять через `build_apply`. +- `camera_list(project_id)` — доступные ракурсы и состояние Camera Worker. +- `camera_capture(camera_id_or_pose, after_operation_id?, profile)` — изображение с метаданными или ID ожидающего задания. +- `asset_list(query, cursor?)` — локальные схематики, размеры, палитры, версии и превью. +- `schematic_export(target, name)` — экспорт в разрешённое хранилище, возвращает artifact ID. +- `schematic_import_prepare(asset_id, transform, target)` — проверить файл и создать план вставки. + +Создание проекта, расширение разрешённой области, выдача прав и снятие защиты относятся к пользовательскому/административному управлению. Агент не может сам расширить свои полномочия вызовом инструмента. + +Общий ответ содержит `schema_version`, `request_id`, `status`, идентификатор мира/epoch, краткую сводку, `warnings`, `truncated` и курсор при необходимости. Чтение указывает момент и полноту наблюдения; изменение всегда возвращает operation ID. Большая операция асинхронна и не должна требовать одного MCP-вызова, открытого на всё время строительства. + +Структурированные ошибки: `permission_denied`, `out_of_bounds`, `unsupported_block`, `stale_snapshot`, `conflict`, `budget_exceeded`, `busy`, `resync_required`, `camera_unavailable`, `capture_not_ready`, `version_mismatch`, `recovery_required`. Ошибка указывает возможность повтора; повтор изменяющего запроса соблюдает idempotency. + +Минимальный протокол между Bridge и плагином версионируется отдельно от MCP/ACP. Каждый запрос содержит correlation ID; команды выполняются от проверенного принципала с ограниченными возможностями, а не от имени произвольного UUID из тела запроса. Ключи и токены не попадают в видимые модели ответы. + +## 15. Хранение, импорт и перенос + +Планируемая структура репозитория: `bridge/`, `paper-plugin/`, `camera-mod/`, `protocol/`, `fixtures/`, `docs/`. В этом документе она описана как будущая; исходный код ещё не создан. + +Постоянные данные хранятся вне исходного кода и вне игровых блоков: + +- На сервере: проекты, области, части, планы, журнал, снимки, рецепты, версии схемы и настройки доступа. +- У Bridge: связь диалогов с проектами, локальная сводка, состояние подключений. +- У камеры: ракурсы, профили и изображения с ID операций. +- В библиотеке: `.schem`, превью и метаданные происхождения, версии, размеров, точки привязки и лицензии. + +Потеря Bridge не теряет историю блоков. Потеря базы плагина не удаляет постройки, но лишает систему достоверных рецептов и отмены. Отсутствие базы не даёт права повторно проиграть старые планы. + +Очистка журнала сохраняет данные активных операций, восстановления и явно закреплённых контрольных точек. Истёкшая история делает соответствующую отмену недоступной; об этом сообщается прямо. Квота диска проверяется до начала записи, а при невозможности сохранить журнал новые изменения останавливаются. Конкретные сроки хранения выбираются после измерения объёма. + +Импорт работает с локальным asset ID, не с произвольным путём или URL модели. Проверяются формат, распакованный объём, число блоков, версия, разрешённые состояния и данные block entities/сущностей. Неподдерживаемое содержимое отклоняется с отчётом, а не молча теряется. Схематика сначала превращается в план и проходит тот же путь конфликтов, что обычное строительство. Форматы загрузки и сохранения предоставляет [WorldEdit Clipboard](https://worldedit.enginehub.org/en/latest/usage/clipboard/). + +Для переноса здания экспортируется `.schem`; для переноса карты сохраняется согласованная резервная копия мира и проверяются измерения и настройки. Метаданные редактора можно приложить отдельным архивом. Перенос на другую серверную основу проверяется на копии и той же версии Minecraft; обратная совместимость со старыми версиями не обещается. Раскладка измерений зависит от серверной основы. [Миграция Paper](https://docs.papermc.io/paper/migration/). + +## 16. Полномочия и эксплуатационные ограничения + +Агент получает доступ только к выбранной строительной области и разрешённому набору инструментов. По умолчанию нет серверной консоли, выдачи OP, изменения плагинов, управления аккаунтами, внешней сети и чтения произвольных файлов через Minecraft MCP. + +Codex запускается в отдельном рабочем каталоге с минимальными правами. Его собственные shell/file-инструменты не должны обходить серверные ограничения или читать секреты Bridge. Конкретный механизм изоляции процесса и доступные режимы закреплённого Codex проверяются в первом прототипе. Подключение MCP само по себе не ограничивает остальные инструменты агента. + +Если Codex/ACP требует разрешение, Bridge связывает запрос с реальным инициатором и показывает конкретное действие. Чужое сообщение в чате не считается разрешением. Отмена и истечение срока закрывают ожидающий запрос. Обычная запись в заранее разрешённой области не должна порождать лишние подтверждения, но это не отменяет ограничения профиля Codex. + +Текст табличек, названия предметов, импортированные метаданные и чужие сообщения рассматриваются как данные мира. Они не могут менять полномочия, системные инструкции или назначение проекта. + +Один серверный план проверяет права и при подготовке, и перед исполнением порций. Отзыв доступа, изменение области или world epoch прекращает дальнейшую запись. При занятости участок ставится в очередь либо возвращает `busy`; скрытой конкуренции между нашими писателями нет. + +## 17. Первоначальные бюджеты и наблюдаемость + +Следующие числа — стартовые настройки прототипа, а не измеренные показатели производительности: + +- До 100 000 изменяемых блоков в одном плане; более крупная стройка делится на осмысленные части. +- До 2 000 000 исследуемых позиций в одной операции подготовки; большая область требует грубого обзора и последующего уточнения. +- Порция записи — не более 512 блоков и целевой предел 5 мс работы нашего исполнителя на тик. Проверка времени идёт между маленькими логическими группами; одна дорогая операция API может превысить цель. +- При перегрузке или росте времени тика размер порции уменьшается, новые порции приостанавливаются. Скорость «блоков в секунду» не фиксируется до замеров. +- Точный текстовый ответ — до 4 096 блоков; страница событий — до 100 элементов; превышение обрабатывается усечением с курсором, а не скрытой потерей данных. +- Кадр по умолчанию — 1280×720; таймаут готовности 20 секунд; не более одной активной съёмки на Worker. +- План действует 10 минут, но проверяется перед применением независимо от возраста. По истечении строится новый план. +- Сигнал остановки принимается сразу; целевой срок прекращения новых порций — до 1 секунды при здоровом сервере и соединении. При зависшем игровом потоке это не гарантия реального времени. + +Измеряются длительности подготовки и порций, время тика с задачей и без неё, объём журнала, размер ответов модели, число снимков и повторов, конфликты, отставание камеры и время остановки. Корреляция строится по project/request/operation/capture ID. + +Токены и стоимость отображаются только по доступным фактическим данным провайдера; отсутствие данных не равно нулю. Не сохраняются скрытые рассуждения модели. Диагностические логи не содержат секретов и по умолчанию не копируют полный игровой чат. + +## 18. Этапы реализации и критерии готовности + +### Этап 0 — проверка совместимости + +Поднять тестовый мир на копии, закрепить версии, проверить ACP-сессию через `codex-acp`, вызов простого MCP-чтения и получение моделью одного изображения. Проверить подключение камеры к Paper, допустимую отдельную сессию наблюдателя и изоляцию Codex. + +Готовность: сообщение из игры доходит до агента; агент получает данные тестового блока и свежий снимок; версии и ограничения записаны. При отсутствии совместимого мода/WorldEdit пересматривается версия платформы до начала строительства реальной карты. + +### Этап 1 — безопасная запись без агента + +Реализовать области, канонические состояния, подготовку плана, порции, idempotency, журнал, конфликт, отмену и восстановление. Проверять прямым тестовым клиентом: работа базового движка не зависит от качества ответов модели. + +Готовность: сервер сохраняет ручную правку между подготовкой и применением; повтор запроса не дублирует работу; отмена не перезаписывает более позднюю правку; остановка и перезапуск дают честное состояние частичного результата. + +### Этап 2 — строительный API и чат + +Добавить примитивы, палитры, повторения, части, MCP-инструменты, очередь ACP и компактные сводки. Строительство маленького здания должно требовать геометрической программы, а не списка отдельных вызовов установки блоков. + +Готовность: из чата создаётся башня с именованной крышей; правка крыши оставляет стену и вручную добавленное окно; конфликт лестницы с ручным окном возвращает точное пересечение. + +### Этап 3 — визуальный цикл + +Добавить сохранённые камеры, готовность чанков/рендера, метаданные свежести и связку кадров с операциями. Агент выполняет ограниченное число осмысленных правок по снимкам. + +Готовность: повторный кадр показывает завершённую правку; незагруженная сцена выдаёт ошибку; пользовательский вид при работе отдельной камеры не переключается. Визуально плохой результат может быть признан плохим, даже если техническая запись успешна. + +### Этап 4 — перенос и выпуск v0.1 + +Добавить `.schem`, локальную библиотеку, копирование проекта и процедуру резервирования. Проверить работу без компонентов редактора на копии мира. + +Готовность: эталонная постройка проходит экспорт/импорт с совпадением поддерживаемых состояний и ориентаций; неподдерживаемые данные не теряются молча; инструкция запуска воспроизводима на чистом окружении. + +## 19. Приёмочные сценарии + +1. **Ручная правка после подготовки.** Изменить блок из write set перед применением. Ожидается конфликт и сохранение ручного значения. +2. **Правка между порциями.** Изменить ещё не записанную часть. Ожидается остановка на пересечении и точный отчёт об уже выполненной работе. +3. **Изменение опоры.** Удалить блок из read set, не входящий в write set. Ожидается перепланирование, а не установка зависящей от него конструкции. +4. **Отмена после ручного изменения.** Изменить блок после строительства и выполнить undo. Ожидается конфликт для этого блока; нет слепого возврата снимка всей области. +5. **Повтор запроса.** Повторить `build_apply` после таймаута. Ожидается тот же operation ID и отсутствие повторной записи. +6. **Падение процесса.** Прерывать сервер до/после сохранения намерения, в середине записи и до отметки завершения. Ожидается `recovery_required`, сверка и отсутствие автоматического уничтожения посторонних состояний. +7. **Потеря журнала изменений.** Запросить дельту устаревшим курсором. Ожидается `resync_required` и новая сводка. +8. **Внешний редактор.** Изменить блок через WorldEdit и через путь без ожидаемого события. Ожидается обнаружение реального расхождения перед нашей записью; источник может быть неизвестен. +9. **Состояния блоков.** Повернуть схему со ступенями, плитами и брёвнами. Ожидаются правильные направления и сохранение состояний после экспорта. +10. **Неподдерживаемые данные.** Попытаться заменить сундук, вставить сущность или импортировать слишком большой файл. Ожидается отказ до записи. +11. **Границы и права.** Выдать план за областью, подменить project ID, отозвать доступ во время записи. Ожидается серверный отказ или прекращение следующих порций. +12. **Камера.** Снимать до загрузки, после изменения и после разрыва связи. Ожидаются достоверные статусы готовности; старое изображение не помечается новым. +13. **Нагрузка.** Применить 10 000 и 100 000 блоков, записать оборудование, версии, настройки и влияние на время тика. Настроить порции по измерениям. +14. **Контекст.** Сравнить малый и большой планы одной формы. Объём обычного ответа модели ограничен сводкой; полный diff остаётся на сервере. +15. **Перенос.** Открыть копию мира без нашего плагина и камеры. Постройка остаётся; потеря функций редактора не меняет блоки. + +Алгоритмы разницы, ограничений, преобразований и idempotency проверяются модульно; потоки, физика, журнал и камера — на настоящем тестовом сервере/клиенте. Моки не доказывают корректность поведения Minecraft. Эти проверки запланированы, но ещё не выполнены. + +## 20. Что заимствуем из Blender MCP + +Из [Blender MCP](https://github.com/ahujasid/blender-mcp) берём сочетание осмотра сцены, работы с именованными объектами, компактного программного построения, изображений и библиотеки ассетов. В Minecraft это превращается в осмотр региона, маски частей, геометрический язык, камеры и `.schem`. + +Собственные дополнения проекта: согласование с ручными правками, серверные порции, журнал до записи, восстановление после сбоя, курсоры дельт и проверка поддерживаемых состояний. Наличие этих функций у Blender MCP не утверждается. Произвольное выполнение Python и сторонние сервисы генерации 3D не копируются в первую версию. + +## 21. Открытые вопросы и последующие версии + +Вопросы не блокируют завершение этого документа; они определяют работы этапа 0 и решения перед соответствующей функцией: + +- Какие точные версии WorldEdit и Fabric совместимы с выбранной веткой 26.2? Если нет общей рабочей комбинации, какую поддерживаемую версию выбрать до строительства карты? +- Где запускается камера и есть ли отдельная игровая сессия для неё? Рабочий вариант — отдельный локальный клиент; запасной — камера в клиенте пользователя. +- Можно ли получить надёжный сигнал готовности геометрии на выбранной версии клиента? Если нет, какой проверяемый критерий свежести достаточен и какие ограничения показывать? +- Использовать ли WorldEdit для фактической записи либо только для форматов и выделений? Решение определяется контролем момента записи, побочных эффектов и времени порции. +- Какие блоки и соседние обновления проходят тесты гарантированной отмены? Расширение списка требует тестов, а не только добавления ID. +- Какое оборудование и размер проектов считать целевыми? До замеров значения раздела 17 остаются бюджетами прототипа. + +v0.2 может добавить трёхстороннее объединение рецептов с ручными поправками, составные блоки, проверку проходов и маршрутов, библиотеку параметрических деталей и более удобное сравнение ракурсов. Для неизвестных построек возможна ручная регистрация частей, позже — предложенная моделью сегментация с проверкой. + +Дальнейшие направления: Fabric-адаптер встроенного сервера для одиночной игры; командное строительство в независимых областях; несколько камер; инструменты проверки мини-игровых карт; изолированные строительные скрипты общего назначения. Они не должны задерживать проверку базового цикла «запрос → план → запись → наблюдение → правка». + +## 22. Итоговые критерии проекта + +Успех первой версии означает, что пользователь может построить и уточнить небольшое здание из игрового чата, агент видит результат, ручная правка не затирается молча, отмена имеет честные ограничения, контекст не заполняется полным миром, а карту можно использовать без редактора. + +Этот документ фиксирует архитектуру и проверяемые требования. Он не подтверждает готовность прототипа, производительность, совместимость всех зависимостей или качество архитектурных решений модели. diff --git a/docs/IMPLEMENTATION.md b/docs/IMPLEMENTATION.md new file mode 100644 index 0000000..251fda3 --- /dev/null +++ b/docs/IMPLEMENTATION.md @@ -0,0 +1,57 @@ +# Состояние реализации + +Первый прототип собран и проверен на настоящем локальном Paper 26.2, включая графический клиент камеры в Prism и передачу изображения через MCP. Он ещё не закрывает всю v0.1 из [дизайна](DESIGN.md): первый авторизованный ход Codex через ACP остаётся непроверенным. + +## Реализованные компоненты + +`world-core` содержит независимый от Bukkit редактор. Декларативный рецепт превращается в неизменяемый план с исходными и желаемыми состояниями, явными зависимостями чтения и сроком действия. Перед записью выполняется проверка; непосредственно при записи состояния сверяются повторно. Дисковые намерения сохраняются до изменения мира, результаты — после порции. Файловый ввод-вывод вынесен с серверного потока. Отмена останавливает следующие порции; уже записанное остаётся в истории. + +`paper-plugin` привязывает это ядро к серверному потоку, проверяет владельца, мир, эпоху, область и защищённые части. HTTP доступен только на loopback, с отдельными ключами администратора и агента. Плагин регистрирует `/ai`, хранит небольшую очередь чата, пересылает ответы только инициатору и управляет телепортацией наблюдателя. Журнал и метаданные находятся в каталоге плагина, игровые блоки остаются ванильными. + +`bridge` предоставляет 14 инструментов MCP по stdio, принимает события чата Paper и запускает закреплённый `codex-acp`. Диалоги разделены по игроку и проекту, очередь сериализована, остановка распространяется на ACP. Идентификатор сессии и компактная сводка сохраняются. Агент получает отдельный MCP-токен; ключ администратора остаётся у Bridge. Большие ответы ограничены, изображения передаются как MCP image content. + +`camera-mod` содержит локальный HTTP Worker и захват framebuffer Minecraft 26.2. Камера ждёт spectator, нужную позицию и измерение, доступность соседних чанков и стабилизацию кадров. Настройки HUD/FOV восстанавливаются после снимка или ошибки. На настоящем клиенте Prism проверены Mixin, серверная телепортация и валидные PNG с нескольких ракурсов. Владелец и наблюдатель использовали один UUID. + +## Подтверждённые проверки + +Текущая сборка: **100 автоматических тестов без ошибок** — 52 в ядре, 17 в Paper-модуле, 20 в Bridge и 11 в модуле камеры. Оба JAR собраны; результаты Java находятся в XML-отчётах Maven/Gradle, общий лог этой проверки — `.runtime/build-final.log`. + +Автоматические Java-тесты проверяют геометрию, ограничения, идемпотентность, зависимости чтения, конфликты, прерывание порции, отмену, undo, журналирование, ошибки диска, восстановление и отдельные HTTP/NBT-контракты. Тесты Bridge используют настоящий MCP stdio и имитатор ACP для диалогов, разрешений, отмены и возобновления. Камера имеет проверки HTTP-аутентификации и валидации, без запуска графического клиента. + +На настоящем Paper, в отдельном созданном тестовом мире, успешно выполнены: + +- Изменение блока после подготовки плана: операция завершается конфликтом, чужой блок сохраняется. +- Изменение явной зависимости: применение останавливается до записи. +- Постройка, повтор с тем же ключом и проверяемая отмена; при более поздней внешней правке undo отвергается. +- Отмена операции, защита неподдерживаемого исходного блока и отказ за пределами области. +- Принудительное завершение собственного процесса сервера во время 4096-блочной операции, повторный запуск, `recovery_required` без автоматического воспроизведения. +- Административный разбор восстановления: агентскому ключу отказано, устаревший digest отвергнут, отказ от продолжения сохраняет текущее содержимое мира и разрешает новые операции после записи решения на диск. Защита части не мешает разбору, но продолжает запрещать запись в неё. +- Полый куб через настоящий MCP: 26 блоков; экспорт `.schem`, библиотека ассетов, undo, импорт на тот же anchor и повторный undo до 27 блоков воздуха. +- Отсутствующая камера возвращает ошибку, без подмены изображения. +- После установки мода в Prism построена 575-блочная башня и получены четыре реальных снимка 1280×720. Последний прошёл весь путь Camera → Paper → Bridge → MCP ImageContent. Первый запрос с изменившимся ракурсом завершился отказом; повтор при неподвижном клиенте успешен. [Протокол проверки](ONE_CLIENT_TEST.md). + +Реальный `codex-acp` прошёл `initialize` в отдельном профиле без входа: ACP v1 и поддержка загрузки сессии подтверждены. Это ещё не проверка хода модели или вызова инструментов после авторизации. Тесты не вызывали модель и не расходовали её токены. + +## Существенные границы + +**Ручные изменения.** Сравнение ожидаемого и текущего состояния защищает от отличающегося блока непосредственно перед записью. Известные события установки/ломания игроком дополнительно инвалидируют владение блока для undo, даже если игрок вернул прежний материал. Полного перехвата изменений других плагинов, команд, физики и всех переходов A→B→A нет; ревизии известных внешних событий пока не сохраняются между запусками. Нельзя считать этот прототип универсальной системой слияния любых параллельных правок. + +**Авария.** JSON-журнал с fsync заменяет запланированную SQLite. Мир Minecraft и наш журнал не образуют одну транзакцию. Неоднозначная операция после сбоя блокирует новые записи. Доступен явный административный обзор и отказ от продолжения по свежему digest, без изменения мира и с отключением неоднозначного undo. Автоматического replay/rollback нет. Полный журнал загружается при старте и пока не имеет архивирования. + +**Область и производительность.** Один владелец, проект и мир; не более 4096 блоков и 512 явных зависимостей на план. Запись — до 128 блоков с целевым бюджетом до 5 мс на порцию; стоимость проверки и JVM не позволяют заявлять жёсткую гарантию времени тика. Полная подготовка ограниченного плана пока выполняется на серверном потоке. Долговременные нагрузочные проверки на большом сервере не проводились. + +**Контекст.** Контекст проекта содержит краткие метаданные, до 20 операций и до 64 частей; точные блоки читаются отдельно. `region_changes` пока возвращает `resync_required`. Поэтому агент повторно читает выбранные участки; обещание «читает только дельты» ещё не реализовано. Диалог ACP имеет сохранение и сводку, но расход модели здесь не измерен. + +**Блоки и схематики.** Разрешён ограниченный набор из 61 ванильного материала, включая часть ступеней и плит; точный список возвращает `project_context`. Waterlogged, контейнеры, двери, redstone и другие сложные блоки не поддерживаются. Проверка окружающих блоков намеренно ограничивает использование возле неподдерживаемой среды. Sponge v2 `.schem` реализован без зависимости от WorldEdit: до 4096 блоков, до 64 файлов, повороты кратно 90°, без сущностей, block entities и биомов, без преобразования между версиями игры. Неподдерживаемый контент отвергается, а не удаляется при экспорте. + +**Строительный язык.** Есть box, line, cylinder и repeat. Арки, произвольные трансформации, декоративные палитры, исполнение JavaScript/Python и автоматическое согласование рецептов с ручными правками пока отсутствуют. Зарегистрированная часть содержит точную маску фактически записанных блоков, а не весь её bounding box. + +**Камера.** Нужен настроенный spectator-клиент; в проверенном сценарии это тот же игрок, что и владелец проекта. Во время снимка он не может продолжать обычное строительство. Автоматического возврата режима и исходной позиции нет. Готовность кадра эвристическая: `serverRevisionVerified: false`. Доступность соседних чанков и стабильность рендера не доказывают получение всех серверных обновлений. Реальная стройка и снимки проверены; сторонние шейдеры, отключение посреди кадра и все варианты зависания окна ещё не проверены. + +**ACP.** Выделены отдельные HOME/CODEX_HOME, отключены лишние интеграции, shell и передача административного токена. Это не изоляция на уровне ОС. Закреплённый адаптер переводит режим `read-only` в `workspace-write`; временные пути могут оставаться доступными, а закреплённый CLI оставляет флаг `unified_exec` включённым при отключённом `shell_tool`. Дополнительные запросы разрешений пока отклоняются. Поведение разрешений динамического Minecraft MCP требует проверки первого настоящего хода. Подробности и диагностика — в [Bridge README](../bridge/README.md). + +## Следующий приёмочный этап + +1. Пользователь входит в выделенный Codex-профиль, подключается к Paper и привязывает владельца. Проверяем маленькую постройку из игрового `/ai`, поток ответа, использование MCP и отмену. +2. Расширяем проверенный сценарий Prism с одним клиентом: проверяем отключение/зависание посреди снимка и удобное переключение между строительством и камерой. +3. Проходим цикл «построил → посмотрел → исправил» и только после этого уточняем готовность релиза, лимиты, дельты контекста и расширение геометрии. diff --git a/docs/ONE_CLIENT_TEST.md b/docs/ONE_CLIENT_TEST.md new file mode 100644 index 0000000..4c2158d --- /dev/null +++ b/docs/ONE_CLIENT_TEST.md @@ -0,0 +1,48 @@ +# Проверка с одним клиентом Prism + +Проверка выполнена в существующем профиле **26.2 MCP Building**: Minecraft 26.2, Fabric Loader 0.19.5, Fabric API 0.160.0+26.2 и Java 25.0.1 из Prism. Установлен собранный `minecraft-builder-camera-0.1.0-SNAPSHOT.jar`. + +Пользователь выбрал автономный профиль для локального теста. В этой рабочей установке `.runtime/server/server.properties` содержит `online-mode=false`, `server-ip=127.0.0.1`, `server-port=25575`; слушающий адрес не расширялся. Это изменение тестовой конфигурации, обычная первоначальная подготовка `dev-server.py` по-прежнему включает проверку аккаунта. + +В приватном конфиге плагина `owner-uuid` и `camera-player-uuid` совпадают. Игроку выданы права оператора на этом тестовом сервере; `allow-local-automation` остаётся выключенным, все вызовы выполнялись с областью владельца. Для фотографий режим временно менялся на spectator. После проверки игрок возвращён в creative на платформе перед башней; автоматического переключения внутри плагина пока нет. + +## Что проверено + +1. Prism загрузил Fabric-мод и подключил одного игрока к Paper. HTTP Worker сообщил подключение и правильный UUID. +2. Через агентский маршрут Paper подготовлена и применена постройка из 575 блоков на ранее пустом участке. Создана именованная часть `One-client camera test tower` с точной маской записи. +3. Первый снимок остановился с `view_changed`, без возврата старого кадра. При повторе неподвижный клиент дал настоящий PNG 1280×720. Метаданные подтверждают заданные yaw/pitch, 20 стабильных тиков и три кадра; запрос занял 2.052 секунды после задержки оператора. +4. Успешно снят второй ракурс и дневной вариант первого. +5. Отдельный MCP stdio-клиент запросил новый снимок и получил один `ImageContent` с PNG. Текстовый блок содержал только метаданные, без base64. Проверены идентификатор, время снимка, сигнатура и размеры PNG. Изображение просмотрено: на нём тестовая башня, кадр без HUD. + +Все изображения — исходные данные Minecraft framebuffer. Они не генерировались нейросетью и не ретушировались. `serverRevisionVerified: false` остаётся честным ограничением: клиентская готовность пока эвристическая. Первый ход модели через `codex-acp` этим тестом не проверялся. + +Локальные результаты: + +- `.runtime/camera-test/build.json` — идентификатор плана/операции, число блоков, часть и область. +- `.runtime/camera-test/20260912T192813Z-2b100930.png` и соседний JSON — первый успешный ракурс. +- `.runtime/camera-test/20260912T192831Z-776d8f9e.png` — второй ракурс. +- `.runtime/camera-test/20260912T192909Z-76b04afd.png` — дневной кадр. +- `.runtime/camera-test/mcp-975cd742-d23f-4b8d-bcc8-dc6e151a8f5c.png` и соседний JSON — изображение, полученное через MCP. + +Башня оставлена для осмотра около `12, 95, 12`, на платформе `x/z=4..20`, `y=94`. Она пересекает область автоматических серверных тестов. Перед их повторным запуском нужен отдельный чистый тестовый мир или проверяемая отмена этой операции; тесты сами не удаляют занятую область. + +## Повторение снимка + +В Prism у профиля настроен wrapper `python3 /путь/к/minecraft-builder-mcp/scripts/camera-wrapper.py`. Он читает приватный ключ камеры из Paper config и передаёт только процессу Java через окружение. Секрет не помещается в `instance.cfg` или командную строку. Исходный `instance.cfg` и `options.txt` сохранены в `.runtime/prism-one-client-backup`. + +После подключения, из игры: + +```text +/gamemode spectator +/ai camera save test +``` + +Сохранять ракурс нужно внутри выбранной области. Из корня проекта: + +```bash +python3 scripts/live-camera-test.py --delay 8 +``` + +Вернуться в окно Minecraft, закрыть меню/чат и не двигаться до завершения. Скрипт сохраняет новый PNG и очищенные метаданные. Затем можно вручную выполнить `/gamemode creative`. + +`bridge/test/live-camera.mjs` отдельно проверяет MCP ImageContent. Он требует доверенные переменные `MCB_AGENT_TOKEN`, `MCB_PLAYER_ID`, `MCB_PROJECT_ID`, JSON-позу `MCB_CAPTURE_POSE` и необязательный `MCB_AFTER_OPERATION_ID`. Снимок перемещает настроенного наблюдателя; это явный интеграционный тест, он не входит в обычный `npm test`. diff --git a/docs/PROTOCOL.md b/docs/PROTOCOL.md new file mode 100644 index 0000000..08fab93 --- /dev/null +++ b/docs/PROTOCOL.md @@ -0,0 +1,73 @@ +# Протокол прототипа + +Версия: 1. Точный реализованный каталог инструментов доступен через MCP `tools/list`, а возможности установленного Paper — через `project_context`. + +## Соединение с Paper + +Сервис слушает только loopback, по умолчанию `127.0.0.1:8765`. `GET /health` возвращает состояние и версию без приватных данных. `POST /v1/rpc` принимает JSON и заголовок `Authorization: Bearer `. + +Запрос: `{ "method": "project_context", "params": { "player_id": "UUID", "project_id": "default" }, "requestId": "correlation-id" }`. + +Успех: `{ "ok": true, "result": { ... } }`. Ошибка: `{ "ok": false, "error": { "code": "...", "message": "..." } }`. Ошибка может сопровождаться HTTP 400/403; клиент обязан читать структурированное тело. Токены в результатах не возвращаются. + +Административный ключ обязателен для `chat_poll`, `chat_reply`, `recovery_review` и `recovery_abandon`. Ключ агента допускает инструменты мира в проекте связанного владельца, но не административное восстановление. `player_id/project_id` добавляются MCP-процессом из настроек, а не предлагаются модели. Плагин повторно проверяет владельца и его действующие права. Для изолированного тестового мира есть явно включаемый `allow-local-automation` и принципал `console`; административные операции восстановления также проходят проверку этой области полномочий. + +## Чтение и запись + +`region_inspect` требует `min/max` как `{x,y,z}` и `detail: "summary" | "blocks"`. Координаты включительные, целочисленные. Лимит прототипа — 4096 позиций. Ответ содержит палитру с количеством, а для `blocks` — точные состояния. Данные берутся из загруженных чанков без неявной генерации мира. + +`build_prepare` принимает `recipe: {version: 1, operations: [...]}`, необязательный массив `dependencies` и необязательный `part_id` для точной маски части. Геометрия: `box(min,max,block,hollow?)`, `line(from,to,block)`, `cylinder(center,radius,height,block,hollow?)`, `repeat(count,offset,operations)`. Используется JSON-описание, не исполняемый JavaScript. + +Результат подготовки: `plan_id`, `plan_hash`, `changed_blocks`, `region`, `expires_at`. Полный план и исходные блоки остаются в журнале. `build_apply` принимает этот ID, хеш и постоянный для данного вызова `idempotency_key`. Повтор того же вызова возвращает ту же операцию. Для другого плана нужен другой ключ. + +`operation_status(operation_id)` возвращает состояние, счётчики и ограниченные примеры конфликтов. `operation_cancel` останавливает следующие порции. `operation_undo_prepare` создаёт обратный план; он проходит обычный `build_apply` и проверки. Потеря ответа на применение не даёт права создать другой ключ и повторить запись: сначала проверить `project_context` или повторить исходный вызов с тем же ключом. + +`part_define(name,operation_id)` регистрирует только фактически записанные блоки завершённой операции. `part_get(part_id)` возвращает имя, границы, количество и защиту. Границы не равны маске: `build_prepare(part_id)` проверяет каждый блок по точной маске. Расширение создаётся отдельной частью. + +Перед записью проверяются реальные состояния целевых блоков и объявленных зависимостей; проверка повторяется после сохранения намерения. Несовпадение не перезаписывается автоматически. Для undo также учитываются известные последующие записи наших операций, даже когда значение блока снова совпадает с прежним результатом. + +Плагин наблюдает неотменённые `BlockPlaceEvent` и `BlockBreakEvent` в настроенном мире. Такое событие лишает прежнюю операцию права на undo данного блока, включая случай «изменили и вернули обратно» (ABA). Эти уведомления хранятся только в памяти текущего процесса. После перезапуска и для внешних путей без наблюдаемого события остаётся проверка текущего содержимого; полная история действий других плагинов не обещается. + +Полный серверный журнал внешних изменений пока не реализован. `region_changes` отвечает `resync_required`; агент использует свежие ограниченные чтения. Это явное ограничение прототипа. + +## Восстановление после сбоя + +Незавершённая операция после перезапуска получает `recovery_required` и запрещает начало новых записей. Автоматического повторения незавершённых порций нет. Административные RPC ниже не входят в MCP-инструменты агента и не являются откатом мира. + +`recovery_review` принимает `operation_id`. Результат использует имена полей Java-записи: `operationId`, `planId`, `positions`, `matchesBefore`, `matchesAfter`, `foreignStates`, `currentDigest`, `sampledAtMillis`. Проверяемая маска объединяет фактически подтверждённые записи предыдущих порций и потенциальные записи незавершённой порции; пропуски без записи не включаются. Счётчики описывают совпадение текущих значений с `before/after`, а `currentDigest` — SHA-256 для этого снимка. Совпадение содержимого не доказывает авторство изменения. + +`recovery_abandon` принимает `operation_id` и `expected_digest`, полученный из `currentDigest` последнего обзора. Сервер заново читает маску и отклоняет запрос, если её содержимое изменилось. При совпадении он оставляет все блоки мира на месте, переводит операцию в `failed` и сохраняет решение до успешного ответа. Ответ имеет обычную форму `operation_status`. + +Отказ от неопределённой истории навсегда запрещает undo этой операции и сохраняет аннулирование прежнего права на undo для блоков затронутой маски. Новые записи разрешаются только после постоянного сохранения решения по всем незавершённым операциям. Сбой до сохранения оставляет необходимость восстановления; потеря ответа требует проверки `operation_status`, а не предположения, что произошёл откат. Устаревший или неверный digest возвращается как RPC-ошибка `invalid_request` с причиной. + +Статус `applied` подтверждает наблюдавшийся результат записи и проверки в работающем сервере. Файлы чанков и журнал не образуют общую транзакцию; автоматической проверки сохранности всех ранее завершённых операций после аварии пока нет. + +## Локальные схемы + +Реализованные имена RPC — `asset_list`, `schematic_export` и `schematic_import_prepare`. Отдельных маршрутов `schematic_list` и немедленного `schematic_import` нет. Наличие этих возможностей проверяется через `project_context`. + +`asset_list` принимает необязательный `query` для поиска по имени без учёта регистра и возвращает `{ "assets": [...] }`. Каждый элемент содержит `assetId`, `name`, `width`, `height`, `length`, `blockCount`, `dataVersion`, `offset`, `sha256`, `bytes`. Каталог содержит до 64 файлов; каждый файл проверяется при чтении, поэтому повреждённая схема может привести к отказу всего запроса списка. + +`schematic_export` принимает `name`, включительные `min/max` и необязательный `origin`. Имя содержит 1–64 печатных символа. `origin` задаёт точку привязки схемы; по умолчанию она равна `min`. Сервер читает плотный прямоугольный участок, включая воздух, в текущей области проекта и в пределах `max_plan_blocks` (не более 4096). Неподдерживаемые блоки и сущности, кроме игроков, приводят к отказу; игроки в схему не записываются. Результат — один объект с теми же полями метаданных, что у `asset_list`. Файл остаётся в подкаталоге `schematics` каталога данных плагина; RPC не возвращает его содержимое или произвольный путь. + +`schematic_import_prepare` принимает `asset_id`, `target: {x,y,z}` и необязательный `rotation: 0 | 90 | 180 | 270` (по умолчанию 0). Поворот выполняется по часовой стрелке при взгляде сверху вокруг точки привязки `target`; сохранённый `offset` учитывается. Изменяются также поддерживаемые направления ступеней и оси брёвен/столбов. Результат — обычный `plan_id/plan_hash/changed_blocks/region/expires_at`; для записи нужен отдельный `build_apply`. Воздух схемы входит в план и может удалять существующие поддерживаемые блоки. Область, исходное содержимое, окружение, политика блоков и конфликты проверяются обычным путём подготовки и применения. + +Поддерживается ограниченное подмножество Sponge Schematic v2: gzip и NBT с палитрой ванильных строительных состояний. Лимиты — 4096 позиций, 1 МиБ сжатого файла и 4 МиБ распакованного NBT. Сущности, block entities, биомы, неизвестные поля верхнего уровня, требуемые модификации и другие версии формата отклоняются. `DataVersion` новее текущего сервера не принимается; преобразования через DataFixer нет. Полная совместимость со всеми схемами WorldEdit не заявляется. + +Внешний `.schem` можно заранее поместить локально в каталог схем под именем `[A-Za-z0-9][A-Za-z0-9_-]{0,63}.schem`, после чего его основание используется как `asset_id`. Произвольные пути, сетевые URL и символические ссылки не принимаются. Загрузки файла через RPC в прототипе нет. + +## Чат + +`chat_poll` принимает стабильный на время жизни Bridge `client_id`. Ответ — `messages` с полями `id`, `playerId`, `projectId`, `text`, `type`, сведениями о положении и блоке под прицелом при наличии. `type` — `prompt` либо `cancel`. Ключ администратора обязателен. + +`chat_reply` принимает `id`, `playerId`, `text`, `done`, необязательный `error`. Ответ маршрутизируется только инициатору исходного запроса. На смену Bridge-процесса незавершённые запросы не проигрываются автоматически: плагин останавливает изменения и просит проверить мир. `/ai stop` приостанавливает новые записи независимо от того, успел ли завершиться ACP-ход. + +## Камера + +`camera_capture` принимает `camera_id` либо `pose: {x,y,z,yaw,pitch,fov?,width?,height?}`. Позиция соответствует ногам наблюдателя; Worker отдельно возвращает координаты глаз. `after_operation_id` проверяет завершение серверной операции, но не гарантирует получения её всех пакетов клиентом. + +Paper сериализует запросы, перемещает настроенного spectator-наблюдателя и отправляет запрос локальному Camera Worker на `127.0.0.1:8766` с отдельным ключом. Pending-ответ содержит `captureId`; для проверки вызывается `camera_capture` с `capture_id`. Готовый результат содержит `imageBase64` и `mimeType`, которые Bridge превращает в MCP image content, не в текстовую base64-строку. + +Снимок имеет эвристическую оценку готовности чанков/кадров. `serverRevisionVerified: false` сохраняется до реализации строгого клиентского подтверждения. Отсутствие камеры, таймаут и невозможность получить свежий кадр не считаются визуальным успехом. + +Подробности Worker: [camera-mod/README.md](../camera-mod/README.md). Потоки и журнал: [world-core/README.md](../world-core/README.md). ACP и изоляция: [bridge/README.md](../bridge/README.md). diff --git a/docs/builds/GOTHIC_HALL.md b/docs/builds/GOTHIC_HALL.md new file mode 100644 index 0000000..0a1767a --- /dev/null +++ b/docs/builds/GOTHIC_HALL.md @@ -0,0 +1,45 @@ +# Готический зал + +Воспроизводимая постройка по [визуальному референсу](../references/gothic-hall-v1.png): большой зал с галереями, меньший двухэтажный корпус, соединительный переход и колокольня. Геометрию создают модули в `scripts/builds/gothic_hall/`, применение выполняет `scripts/build-gothic-hall.py` через проверяемые операции Paper-плагина. + +Это адаптация референса доступной палитрой: каменный кирпич, андезит, диорит, тёмный сланец, древесина и затемнённое стекло. Шейдеры не используются. Детальная меблировка и ландшафт пока не выполнены; имеются терраса, лестницы, простые скамьи и помост. Колокол собран из блоков, без сущности колокола. + +Постройка применена в живом мире: **29 354 блока, 87 пакетов**, итоговая проверка сохранена в `.runtime/gothic-hall/verification.json`. + +![Постройка в Minecraft без шейдеров](gothic-hall-built.png) + +Настоящий снимок Fabric-камеры от 12 сентября 2026 года, после финальной отделки. PNG сохранён без обработки; [метаданные снимка](gothic-hall-built.capture.json). Ракурс: `(0, -20, -63)`, yaw `25°`, pitch `16°`, FOV `85°`. Дальность тестового сервера — пять чанков, поэтому дальние края скрываются в тумане. Готовность кадра проверяется по клиенту; совпадение блоков с чертежом проверено отдельно чтением мира. + +Начало локальных координат — **origin = (-50, -60, -40)**. Мировая координата получается прибавлением origin к локальной. Все диапазоны ниже включают обе границы; главные фасады обращены на север, в сторону `−Z`. + +- Терраса: локально `X=1..63, Z=1..62, Y=0`, размер **63 × 62**. В мире: `X=-49..13, Z=-39..22, Y=-60`. +- Большой зал: основной корпус `X=7..31, Z=13..56`, размер **25 × 44**; с декором занимает `X=3..35, Z=8..57, Y=0..48`. Основной пол на `Y=6`, галереи на `Y=16`, конёк на `Y=43`. Главный вход около мировой точки **(-31, -53, -31)**. +- Боковой корпус: основное пятно `X=38..57, Z=16..41`, размер **20 × 26**; с выступами `X=36..59, Z=13..43, Y=0..28`. Полы на `Y=0/8`, конёк на `Y=28`. Вход около **(-3, -59, -26)**. +- Колокольня: с выступами `X=34..46, Z=42..56, Y=0..60`, размер **13 × 15**, 61 уровень блоков. Вершина в мире на `Y=0`; вход около **(-10, -58, 2)**. +- Соединительный переход: `X=31..40, Z=33..43, Y=0..16`; проход в полосе `Z=36..39` поднимается с пола большого зала `Y=6` к полу бокового корпуса `Y=8`. + +Команды выполняются из корня репозитория при запущенном Paper-сервере, подключённом владельце проекта и загруженных чанках стройплощадки. Скрипт читает область проекта и отдельный агентский токен из приватного `.runtime/server/plugins/MinecraftBuilderMCP/config.yml`. + +```bash +python3 scripts/build-gothic-hall.py plan +python3 scripts/build-gothic-hall.py apply +python3 scripts/build-gothic-hall.py verify +``` + +`plan` сохраняет `.runtime/gothic-hall/manifest.json`, проверяет палитру, границы проекта и точность сжатия геометрии в рецепты. Мир эта команда не меняет. `apply` перед каждым новым пакетом проверяет, что его область пуста, затем выполняет `build_prepare` и `build_apply`. Состояния блоков в сохранённом плане также должны ожидать воздух. `verify` сравнивает записанные блоки с результатом в мире и сохраняет отчёт; пустые пространства вне записанной маски она не проверяет. + +Журнал возобновления — `.runtime/gothic-hall/ledger.json`. В нём сохраняются хеш чертежа, планы, ключи идемпотентности, идентификаторы операций и их статусы. Повторный `apply` продолжает по этому журналу, используя существующие операции; завершённые пакеты заново не строятся. Также проверяются идентификатор мира и его эпоха. + +Занятая область, ручное изменение ожидаемого блока, незавершённая или конфликтующая операция останавливают применение. `verify` сообщает расхождения, сохраняя ручные правки. При остановке нужно проверить указанную операцию и её серверный журнал; удалять ledger или автоматически создавать новый план поверх существующей постройки нельзя. Серверные планы находятся в `plugins/MinecraftBuilderMCP/journal/plans/` и нужны этому скрипту для применения и проверки. + +Финальная отделка хранится отдельно от исходного чертежа: `scripts/finish-gothic-hall.py` заменяет 14 центральных плит наверший на целые каменные блоки и четыре блока перехода на ступени. Это убирает зазоры в шпилях и делает подъём между корпусами плавным. Используются отдельный план и `.runtime/gothic-hall/finish-ledger.json`. + +```bash +python3 scripts/finish-gothic-hall.py plan +python3 scripts/finish-gothic-hall.py apply +python3 scripts/finish-gothic-hall.py verify +``` + +После отделки следует использовать последнюю команду: она проверяет исходную постройку с учётом 18 замен. Исходная `build-gothic-hall.py verify` ожидает прежние состояния этих блоков и сообщит о расхождениях. + +Отделка применена операцией `682bb5bb-a1fe-4359-b687-06f7b1ce1005`. Повторная проверка всех 29 354 блоков прошла; отчёт — `.runtime/gothic-hall/finish-verification.json`. Мир сохранён через `save-all flush`. diff --git a/docs/builds/gothic-hall-built.capture.json b/docs/builds/gothic-hall-built.capture.json new file mode 100644 index 0000000..0692d6b --- /dev/null +++ b/docs/builds/gothic-hall-built.capture.json @@ -0,0 +1,29 @@ +{ + "capturedAt": "2026-09-12T20:10:04.926155338Z", + "dimension": "minecraft:overworld", + "x": 0.0, + "y": -20.0, + "z": -63.0, + "eyeY": -18.380000114440918, + "yaw": 25.0, + "pitch": 16.0, + "fov": 85, + "readiness": "local_chunks_and_render_queue_stable", + "serverRevisionVerified": false, + "loadedChunkRadius": 1, + "stabilizationTicks": 21, + "stabilizationFrames": 3, + "afterOperationId": "682bb5bb-a1fe-4359-b687-06f7b1ce1005", + "status": "completed", + "mimeType": "image/png", + "width": 1280, + "height": 720, + "sourceWidth": 1280, + "sourceHeight": 720, + "captureId": "aa1dbef8-47f1-4985-b505-0bcc79e604b7", + "imageSha256": "b1dfd2fd671c20d7a0e4fd225843695a522f93a194cea18bfa313297e303774a", + "imageBytes": 670102, + "elapsedSeconds": 1.367, + "transport": "authenticated Paper HTTP camera_capture", + "testMode": "one owner/spectator client" +} diff --git a/docs/builds/gothic-hall-built.png b/docs/builds/gothic-hall-built.png new file mode 100644 index 0000000..a5ddbb6 Binary files /dev/null and b/docs/builds/gothic-hall-built.png differ diff --git a/docs/compatibility.json b/docs/compatibility.json new file mode 100644 index 0000000..3b72e51 --- /dev/null +++ b/docs/compatibility.json @@ -0,0 +1,44 @@ +{ + "schemaVersion": 1, + "projectVersion": "0.1.0-SNAPSHOT", + "status": "prototype; graphical camera and MCP ImageContent verified; authenticated ACP model turn pending", + "testedPlatform": "Linux x86_64", + "minecraft": "26.2", + "paper": { + "build": 123, + "api": "26.2.build.123-stable", + "serverSha256": "7b7b3b43c009103e1971a0576c26f655a7dd9b56a0a2a4438e352c03a7fecd08" + }, + "java": "25.0.2", + "maven": "3.9.11", + "nodeTested": "22.22.3", + "fabric": { + "loader": "0.19.5", + "api": "0.160.0+26.2", + "loom": "1.17.20", + "gradle": "9.5.1" + }, + "bridge": { + "codexAcp": "1.11.0", + "codex": "0.153.4", + "acpSdk": "1.4.0", + "mcpSdk": "1.30.0", + "typescript": "7.0.2", + "zod": "4.6.2" + }, + "schematic": { + "format": "Sponge v2", + "worldEditRuntimeRequired": false, + "maxBlocks": 4096, + "entities": false, + "blockEntities": false + }, + "dependencySources": [ + "../pom.xml", + "../bridge/package-lock.json", + "../camera-mod/gradle.properties", + "../camera-mod/gradle/wrapper/gradle-wrapper.properties", + "../scripts/bootstrap-tools.py", + "../scripts/dev-server.py" + ] +} diff --git a/docs/references/gothic-hall-v1.png b/docs/references/gothic-hall-v1.png new file mode 100644 index 0000000..446da53 Binary files /dev/null and b/docs/references/gothic-hall-v1.png differ diff --git a/docs/references/gothic-hall-v1.prompt.txt b/docs/references/gothic-hall-v1.prompt.txt new file mode 100644 index 0000000..c2ff4fa --- /dev/null +++ b/docs/references/gothic-hall-v1.prompt.txt @@ -0,0 +1,16 @@ +Generated using the built-in image_gen tool. +Concept reference for minecraft-builder-mcp; not an in-game screenshot or a dimensioned construction plan. + +Use case: stylized-concept. +Asset type: architectural reference for a substantial building we will later construct in Minecraft Java. +Primary request: one impressive but realistically buildable Gothic civic guildhall / town hall, significantly larger and more sophisticated than a small watchtower. + +Subject: a coherent single building with a long three-storey great hall, a steep dark gabled roof, a prominent square bell tower with a tall pointed roof, a lower connected side wing and an entrance stair. Approximately 65 by 45 Minecraft blocks in footprint, highest tower around 55 blocks; communicate substantial scale through believable block sizes. Strong readable silhouette and balanced architectural hierarchy. The front has a deep pointed-arch entrance and a restrained series of tall, narrow RECESSED windows with stone jambs and lintels. Glass sits inside thick masonry: absolutely no protruding glass boxes. A few recessed paired lancet windows on the great hall. Buttresses, structural masonry, layered cornices and roofs made of explicit block steps; purposeful details with some calm wall areas. The attached wing and tower must visibly connect to usable interior volumes. + +Style/medium: beautiful high-quality Minecraft voxel architectural concept render, faithful cubic full-block, stair and slab construction at consistent voxel scale, crisp recognisable vanilla-like pixel textures. All edges obey the Minecraft grid. Not a smooth realistic European building with a token pixel filter. +Materials: predominantly grey stone bricks with restrained polished andesite and light stone trim, dark deepslate stair/slab roofs, dark oak and spruce for doors and inset timber features, dark glass deep in the walls. Restrained palette; strong depth and masonry shadows, no random patchwork. +Scene: the complete building stands on a small paved terrace at ground level, with minimal grass terrain and a quiet pale sky. Sparse setting keeps the architecture fully readable. +Composition: a single landscape image, elevated front three-quarter architectural view showing the entrance, long side facade, roof structure and tower; the full building and entire highest roof are inside the frame with breathing room. Moderate perspective, no fisheye. Building fills most of the composition. +Lighting: clear soft daylight with warm sunlight, readable shadow detail; avoid night, fog or overexposure. +Avoid: people, mobs, UI, HUD, captions, labels, text, watermarks, logos, floating islands, mountains obscuring the building, sprawling cities, enormous fantasy spires, excessive clutter or unbuildable smooth curves. +Generate a brand-new standalone reference, not an edit or screenshot of an existing build. diff --git a/mvnw b/mvnw new file mode 100755 index 0000000..6b9b560 --- /dev/null +++ b/mvnw @@ -0,0 +1,9 @@ +#!/usr/bin/env bash +set -euo pipefail +project_root="$(cd -- "$(dirname -- "$0")" && pwd)" +tool_cache="${MCB_TOOL_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/minecraft-builder-mcp}" +if [[ ! -x "$tool_cache/apache-maven-3.9.11/bin/mvn" || ! -x "$tool_cache/jdk-25.0.2/bin/java" ]]; then + MCB_TOOL_CACHE="$tool_cache" python3 "$project_root/scripts/bootstrap-tools.py" +fi +export JAVA_HOME="${MCB_JAVA_HOME:-$tool_cache/jdk-25.0.2}" +exec "$tool_cache/apache-maven-3.9.11/bin/mvn" "$@" diff --git a/paper-plugin/pom.xml b/paper-plugin/pom.xml new file mode 100644 index 0000000..ace0cb0 --- /dev/null +++ b/paper-plugin/pom.xml @@ -0,0 +1,15 @@ + + 4.0.0 + io.github.minecraftbuilderminecraft-builder-mcp0.1.0-SNAPSHOT + paper-plugin + papermchttps://repo.papermc.io/repository/maven-public/ + + io.github.minecraftbuilderworld-core${project.version} + io.papermc.paperpaper-api${paper.version}provided + com.google.code.gsongson2.14.0 + org.junit.jupiterjunit-jupiter5.13.4test + + + org.apache.maven.pluginsmaven-shade-plugin3.6.1packageshadefalse*:*META-INF/*.SFMETA-INF/*.RSAMETA-INF/*.DSA + + diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuilderPlugin.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuilderPlugin.java new file mode 100644 index 0000000..fcd52af --- /dev/null +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuilderPlugin.java @@ -0,0 +1,436 @@ +package io.github.minecraftbuilder.paper; + +import com.google.gson.*; +import io.github.minecraftbuilder.core.*; +import org.bukkit.*; +import org.bukkit.command.*; +import org.bukkit.entity.Player; +import org.bukkit.event.*; +import org.bukkit.event.block.BlockBreakEvent; +import org.bukkit.event.block.BlockPlaceEvent; +import org.bukkit.plugin.java.JavaPlugin; +import java.io.*; +import java.net.URI; +import java.net.http.*; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.*; +import java.time.Duration; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.function.Supplier; + +import static io.github.minecraftbuilder.paper.RpcServer.Fault; + +public final class BuilderPlugin extends JavaPlugin { + private final Gson json = new Gson(); + private RpcServer http; + private ExecutorService disk; + private EditEngine engine; + private World world; + private BuildingWorld access; + private SchematicAssets assets; + private Region region; + private String projectId, epoch, owner, adminToken, agentToken; + private volatile boolean ioBusy, halted; + private boolean writesPaused; + private int maxBlocks; + private final Map messages = new LinkedHashMap<>(); + private final Map parts = new LinkedHashMap<>(); + private final Map cameras = new LinkedHashMap<>(); + private final Set captureIds = new HashSet<>(); + private final Map leased = new HashMap<>(); + private long lastPoll; + private String chatClientId; + private long cameraBusyUntil; + private String activeCaptureId; + private final HttpClient cameraHttp = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(3)).build(); + record Part(String id,String name,String operationId,Set positions,boolean locked) {} + + @Override public void onEnable() { + try { + saveDefaultConfig(); + for (String key : List.of("admin-token","agent-token","camera-token")) + if (getConfig().getString(key,"").isBlank()) getConfig().set(key, randomToken()); + ComponentTokens.validate(getConfig().getString("admin-token"), getConfig().getString("agent-token"), getConfig().getString("camera-token")); + if (getConfig().getString("world-epoch","").isBlank()) getConfig().set("world-epoch", UUID.randomUUID().toString()); + saveConfig(); + try { Files.setPosixFilePermissions(getDataFolder().toPath().resolve("config.yml"), java.nio.file.attribute.PosixFilePermissions.fromString("rw-------")); } + catch (UnsupportedOperationException ignored) { } + projectId=getConfig().getString("project-id","default"); epoch=getConfig().getString("world-epoch"); owner=getConfig().getString("owner-uuid",""); + adminToken=getConfig().getString("admin-token"); agentToken=getConfig().getString("agent-token"); + world=Bukkit.getWorld(getConfig().getString("world","world")); + if (world==null) throw new IllegalStateException("Configured world is not loaded"); + region=new Region(world.getUID().toString(), configPos("region.min"), configPos("region.max")); + maxBlocks=Math.min(4096,Math.max(1,getConfig().getInt("max-plan-blocks",4096))); + access=new BuildingWorld(world); + assets=new SchematicAssets(getDataFolder().toPath().resolve("schematics")); + loadMetadata(); + disk=Executors.newSingleThreadExecutor(r->{Thread t=new Thread(r,"mcb-journal");t.setDaemon(true);return t;}); + Limits limits=new Limits(maxBlocks,512,Math.min(128,Math.max(1,getConfig().getInt("slice-blocks",128))), + Math.min(5,Math.max(1,getConfig().getInt("slice-millis",5)))*1_000_000L,600_000,32); + engine=new EditEngine(access,access,new ContextGuard(){ + public void check(Plan plan){guard(plan);} + public void checkRecovery(Plan plan){guardContext(plan);} + },new JsonJournal(getDataFolder().toPath().resolve("journal")),limits); + Bukkit.getPluginManager().registerEvents(new Listener(){ + @EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true) + public void placed(BlockPlaceEvent event){if(event.getBlock().getWorld().equals(world))engine.recordExternal(new BlockPos(event.getBlock().getX(),event.getBlock().getY(),event.getBlock().getZ()));} + @EventHandler(priority=EventPriority.MONITOR,ignoreCancelled=true) + public void broken(BlockBreakEvent event){if(event.getBlock().getWorld().equals(world))engine.recordExternal(new BlockPos(event.getBlock().getX(),event.getBlock().getY(),event.getBlock().getZ()));} + },this); + http=new RpcServer(getConfig().getInt("http-port",8765),adminToken,agentToken,this::rpc); + Objects.requireNonNull(getCommand("ai")).setExecutor(this::command); + Bukkit.getScheduler().runTaskTimer(this,this::tick,1,1); + getLogger().info("Builder ready on loopback port "+http.port()+". Run /ai setup as the project owner."); + } catch (Exception e) { + getLogger().severe("Builder disabled: "+e.getMessage()); + getServer().getPluginManager().disablePlugin(this); + } + } + @Override public void onDisable() { + if(http!=null) http.close(); + if(disk!=null) { disk.shutdown(); try { if(!disk.awaitTermination(3,TimeUnit.SECONDS)) disk.shutdownNow(); } catch(InterruptedException e){Thread.currentThread().interrupt();} } + } + private BlockPos configPos(String key) { return new BlockPos(getConfig().getInt(key+".x"),getConfig().getInt(key+".y"),getConfig().getInt(key+".z")); } + private static String randomToken() { byte[] bytes=new byte[32];new SecureRandom().nextBytes(bytes);return HexFormat.of().formatHex(bytes); } + private T main(Callable work) throws Exception { + if(Bukkit.isPrimaryThread()) return work.call(); + Future task=Bukkit.getScheduler().callSyncMethod(this,work); + try { return task.get(20,TimeUnit.SECONDS); } + catch(TimeoutException e){task.cancel(false);throw e;} + } + private void guard(Plan p) { + guardContext(p); + if(halted) throw new Fault("recovery_required","Journal IO failed; restart and inspect recovery"); + if(writesPaused) throw new Fault("cancelled","Writing paused by the owner; send a new request or /ai resume"); + for(Change c:p.changes())if(!c.expected().equals(c.desired())) + for(Part part:parts.values())if(part.locked()&&part.positions().contains(c.pos())) + throw new Fault("permission_denied","Part is protected: "+part.name()); + } + // Recovery inspection/abandonment never writes blocks. Retain its context + // authorization without applying the pause or protected-part write veto. + private void guardContext(Plan p) { + if(!p.projectId().equals(projectId)||!p.worldEpoch().equals(epoch)||!p.region().worldId().equals(world.getUID().toString())) + throw new Fault("version_mismatch","Project or world changed"); + if(!getConfig().getBoolean("allow-local-automation",false)) { + Player player=owner.isBlank()?null:Bukkit.getPlayer(UUID.fromString(owner)); + if(player==null||!player.hasPermission("minecraftbuilder.use")) throw new Fault("permission_denied","Project owner must be online and authorised"); + } + for(Change c:p.changes()) { + if(!region.contains(c.pos()))throw new Fault("out_of_bounds","Plan exceeds current project area"); + } + for(Plan.Dependency dependency:p.dependencies())if(!region.contains(dependency.pos()))throw new Fault("out_of_bounds","Dependency exceeds current project area"); + } + private void scoped(JsonObject p,boolean admin) { + if(!str(p,"project_id",projectId).equals(projectId))throw new Fault("permission_denied","Unknown project scope"); + String player=str(p,"player_id",""); + if(getConfig().getBoolean("allow-local-automation",false)&&player.equals("console"))return; + if(owner.isBlank()||!player.equals(owner))throw new Fault("permission_denied","Only the bound project owner can use this capability; run /ai setup"); + Player online=Bukkit.getPlayer(UUID.fromString(owner)); + if(online==null||!online.hasPermission("minecraftbuilder.use"))throw new Fault("permission_denied","Owner is offline or permission was revoked"); + } + private void available() { if(halted)throw new Fault("recovery_required","Journal unavailable");if(ioBusy)throw new Fault("busy","A journal step is in progress; retry"); } + + private Object rpc(String method,JsonObject p,boolean admin)throws Exception { + if(method.equals("chat_poll"))return main(()->pollChat(p)); + if(method.equals("chat_reply"))return main(()->reply(p)); + main(()->{scoped(p,admin);return null;}); + switch(method) { + case "recovery_review":{ + if(!admin)throw new Fault("permission_denied","Recovery requires explicit administrator capability"); + return main(()->engine.reviewRecovery(required(p,"operation_id"))); + } + case "recovery_abandon":{ + if(!admin)throw new Fault("permission_denied","Recovery requires explicit administrator capability"); + OperationView view=main(()->{available();OperationView v=engine.abandonRecovery(required(p,"operation_id"),required(p,"expected_digest"));ioBusy=true;return v;}); + try{engine.flushOperation(view.id());return status(engine.status(view.id()));}catch(Exception e){halted=true;throw e;}finally{main(()->{ioBusy=false;return null;});} + } + case "asset_list":return Map.of("assets",assets.list().stream().filter(a->a.name().toLowerCase(Locale.ROOT).contains(str(p,"query","").toLowerCase(Locale.ROOT))).toList()); + case "schematic_export":{ + Map snapshot=main(()->{ + Region area=new Region(world.getUID().toString(),pos(p.getAsJsonObject("min")),pos(p.getAsJsonObject("max"))); + if(!region.contains(area.min())||!region.contains(area.max()))throw new Fault("out_of_bounds","Export exceeds project region"); + if(area.volume()>maxBlocks)throw new Fault("budget_exceeded","Export at most "+maxBlocks+" blocks"); + org.bukkit.util.BoundingBox box=new org.bukkit.util.BoundingBox(area.min().x(),area.min().y(),area.min().z(),area.max().x()+1.0,area.max().y()+1.0,area.max().z()+1.0); + if(world.getNearbyEntities(box).stream().anyMatch(e->!(e instanceof Player)))throw new Fault("unsupported_entity","Export region contains entities; this prototype exports blocks only"); + Map data=new LinkedHashMap<>(); + for(int y=area.min().y();y<=area.max().y();y++)for(int z=area.min().z();z<=area.max().z();z++)for(int x=area.min().x();x<=area.max().x();x++){ + BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at);if(!access.supports(state))throw new Fault("unsupported_block","Export contains unsupported block "+state);data.put(at,state); + }return data; + }); + return assets.exportSnapshot(required(p,"name"),snapshot,p.has("origin")?pos(p.getAsJsonObject("origin")):pos(p.getAsJsonObject("min")),main(()->Bukkit.getUnsafe().getDataVersion())); + } + case "schematic_import_prepare":{ + Map data=new LinkedHashMap<>(assets.read(required(p,"asset_id"),pos(p.getAsJsonObject("target")),p.has("rotation")?integer(p,"rotation"):0,main(()->Bukkit.getUnsafe().getDataVersion()))); + Plan plan=main(()->{available();data.replaceAll((at,state)->access.canonical(state));for(BlockPos at:data.keySet())checkSurroundings(at,data);Plan v=engine.prepare(projectId,epoch,region,data,Set.of());ioBusy=true;return v;}); + try{engine.persistPlan(plan.id());return planSummary(plan);}finally{main(()->{ioBusy=false;return null;});} + } + case "project_context": return main(this::context); + case "region_inspect": return main(()->inspect(p)); + case "region_changes": return Map.of("status","resync_required","reason","Prototype uses fresh bounded reads; complete event delta journal is not implemented"); + case "build_prepare": { + JsonObject recipe=p.getAsJsonObject("recipe"); + Map desired=new LinkedHashMap<>(RecipeCompiler.compile(recipe,maxBlocks)); + Set dependencies=new LinkedHashSet<>(); + if(p.has("dependencies"))for(JsonElement e:p.getAsJsonArray("dependencies"))dependencies.add(pos(e.getAsJsonObject())); + if(dependencies.size()>512)throw new Fault("budget_exceeded","At most 512 explicit dependencies supported"); + Plan plan=main(()->{ + available(); + desired.replaceAll((k,v)->access.canonical(v)); + if(p.has("part_id")){ + Part part=parts.get(required(p,"part_id"));if(part==null)throw new Fault("not_found","Part not found"); + if(!part.positions().containsAll(desired.keySet()))throw new Fault("out_of_bounds","Patch exceeds the exact part mask; create a new part for an extension"); + } + for(BlockPos at:desired.keySet())checkSurroundings(at,desired); + Plan value=engine.prepare(projectId,epoch,region,desired,dependencies);ioBusy=true;return value; + }); + try { engine.persistPlan(plan.id()); return planSummary(plan); } + finally {main(()->{ioBusy=false;return null;});} + } + case "build_apply": { + OperationView operation=main(()->{ + available();Plan plan=engine.plan(required(p,"plan_id")); + if(!hash(plan).equals(required(p,"plan_hash")))throw new Fault("stale_snapshot","Plan hash does not match"); + guard(plan);OperationView value=engine.start(plan.id(),required(p,"idempotency_key"));ioBusy=true;return value; + }); + try{engine.flushOperation(operation.id());return status(engine.status(operation.id()));} + catch(Exception e){halted=true;throw e;} + finally{main(()->{ioBusy=false;return null;});} + } + case "operation_status":return main(()->status(engine.status(required(p,"operation_id")))); + case "operation_cancel":return main(()->status(engine.cancel(required(p,"operation_id")))); + case "operation_undo_prepare":{ + Plan plan=main(()->{available();Plan value=engine.prepareUndo(required(p,"operation_id"));ioBusy=true;return value;}); + try{engine.persistPlan(plan.id());return planSummary(plan);}finally{main(()->{ioBusy=false;return null;});} + } + case "part_define":{ + Object result=main(()->definePart(p));saveMetadata();return result; + } + case "part_get":return main(()->{ + Part part=parts.get(required(p,"part_id"));if(part==null)throw new Fault("not_found","Part not found"); + return Map.of("part_id",part.id(),"name",part.name(),"block_count",part.positions().size(),"protected",part.locked(),"operation_id",part.operationId(),"bounds",bounds(part.positions())); + }); + case "camera_list":return main(()->Map.of("cameras",new ArrayList<>(cameras.values()),"configured",!getConfig().getString("camera-player-uuid","").isBlank())); + case "camera_capture":return capture(p); + default:throw new Fault("unsupported_method","Method not implemented: "+method); + } + } + private void checkSurroundings(BlockPos at,Map desired) { + if(!region.contains(at))throw new Fault("out_of_bounds","Recipe exceeds project region"); + for(BlockPos d:List.of(new BlockPos(1,0,0),new BlockPos(-1,0,0),new BlockPos(0,1,0),new BlockPos(0,-1,0),new BlockPos(0,0,1),new BlockPos(0,0,-1))) { + BlockPos neighbor=at.add(d); + if(neighbor.y()=world.getMaxHeight()||desired.containsKey(neighbor))continue; + String state=access.getBlock(neighbor); + if(!access.supports(state))throw new Fault("unsupported_block","Unsupported adjacent environment at "+neighbor+"; use a controlled construction area"); + } + } + private Object context() { + return Map.ofEntries(Map.entry("schema_version",1),Map.entry("project_id",projectId),Map.entry("world_id",world.getUID().toString()),Map.entry("world_epoch",epoch),Map.entry("region",region), + Map.entry("max_plan_blocks",maxBlocks),Map.entry("parts",parts.values().stream().limit(64).map(p->Map.of("part_id",p.id(),"name",p.name(),"protected",p.locked(),"block_count",p.positions().size())).toList()), + Map.entry("parts_total",parts.size()),Map.entry("operations",engine.recentOperations(20).stream().map(v->Map.of("operation_id",v.id(),"plan_id",v.planId(),"status",v.status().name().toLowerCase(Locale.ROOT),"written",v.written(),"total_changes",v.totalChanges())).toList()), + Map.entry("operations_total",engine.operationCount()),Map.entry("truncated",parts.size()>64||engine.operationCount()>20), + Map.entry("supported_materials",BuildingWorld.supportedMaterials()),Map.entry("recipe",Map.of("version",1,"operations",List.of("box","line","cylinder","repeat"))),Map.entry("capabilities",List.of("region_inspect","build_prepare","build_apply","operation_status","operation_cancel","operation_undo_prepare","part_define","part_get","camera_list","camera_capture","asset_list","schematic_export","schematic_import_prepare")), + Map.entry("limitations",List.of("One configured owner and project","Loaded chunks only","No automatic recipe merge","Complete delta journal is not implemented","Camera readiness is heuristic","Sponge schematic v2 only; no entities or block entities"))); + } + private Object inspect(JsonObject p) { + Region area=new Region(world.getUID().toString(),pos(p.getAsJsonObject("min")),pos(p.getAsJsonObject("max"))); + if(!region.contains(area.min())||!region.contains(area.max()))throw new Fault("out_of_bounds","Read exceeds project area"); + if(area.volume()>4096)throw new Fault("budget_exceeded","Read at most 4096 blocks per request"); + Map palette=new TreeMap<>();List blocks=new ArrayList<>(); + boolean exact=str(p,"detail","summary").equals("blocks"); + for(int y=area.min().y();y<=area.max().y();y++)for(int z=area.min().z();z<=area.max().z();z++)for(int x=area.min().x();x<=area.max().x();x++){ + BlockPos at=new BlockPos(x,y,z);String state=access.getBlock(at);palette.merge(state,1,Integer::sum);if(exact)blocks.add(Map.of("pos",at,"state",state)); + } + return Map.of("region",area,"palette",palette,"blocks",blocks,"sampled_at",System.currentTimeMillis(),"world_epoch",epoch,"truncated",false); + } + private Map planSummary(Plan plan) { + return Map.of("plan_id",plan.id(),"plan_hash",hash(plan),"changed_blocks",plan.changes().stream().filter(c->!c.expected().equals(c.desired())).count(),"region",plan.region(),"expires_at",plan.expiresAtMillis()); + } + private Map status(OperationView v) { + return Map.ofEntries(Map.entry("operation_id",v.id()),Map.entry("plan_id",v.planId()),Map.entry("status",v.status().name().toLowerCase(Locale.ROOT)),Map.entry("written",v.written()),Map.entry("total_changes",v.totalChanges()),Map.entry("processed",v.processed()),Map.entry("conflicts",v.conflicts()),Map.entry("message",Objects.toString(v.message(),""))); + } + private String hash(Plan p) { + try{return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(json.toJson(p).getBytes(StandardCharsets.UTF_8)));}catch(NoSuchAlgorithmException e){throw new AssertionError(e);} + } + private void tick() { + if(ioBusy||halted||engine==null)return; + for(OperationView view:engine.pendingOperations()) { + if(!view.status().terminal()&&lastPoll>0&&System.currentTimeMillis()-lastPoll>15_000&&!getConfig().getBoolean("allow-local-automation",false))engine.cancel(view.id()); + if(view.needsFlush()){flushAsync(view.id());return;} + if(view.status().terminal())continue; + try { + Optional next=engine.stageSlice(view.id()); + if(next.isEmpty()){if(engine.status(view.id()).needsFlush())flushAsync(view.id());return;} + ioBusy=true;SliceIntent intent=next.get(); + disk.submit(()->{ + try { + engine.persistIntent(intent); + main(()->{engine.commitSlice(intent);return null;}); + engine.flushOperation(view.id()); + }catch(Exception e){halted=true;getLogger().severe("Journal/apply stopped: "+e.getClass().getSimpleName()+"; inspect recovery after restart");} + finally{ioBusy=false;} + });return; + }catch(Exception e){engine.cancel(view.id());getLogger().warning("Operation stopped: "+e.getMessage());return;} + } + } + private void flushAsync(String id) { + ioBusy=true;disk.submit(()->{try{engine.flushOperation(id);}catch(Exception e){halted=true;getLogger().severe("Journal flush failed; editing halted");}finally{ioBusy=false;}}); + } + private Object definePart(JsonObject p) { + available();OperationView v=engine.status(required(p,"operation_id")); + if(v.status()!=OperationStatus.APPLIED)throw new Fault("busy","Part requires an applied operation"); + Set positions=new HashSet<>(); + for(Receipt receipt:engine.receipts(v.id()))positions.add(receipt.change().pos()); + if(positions.isEmpty())throw new Fault("invalid_request","Operation has no owned blocks"); + if(parts.size()>=64)throw new Fault("budget_exceeded","Prototype supports 64 named parts per project"); + for(Part existing:parts.values())if(!Collections.disjoint(existing.positions(),positions))throw new Fault("conflict","Part overlaps "+existing.name()); + String name=required(p,"name");if(name.length()>64)throw new Fault("invalid_request","Part name too long"); + Part value=new Part(UUID.randomUUID().toString(),name,v.id(),Set.copyOf(positions),false);parts.put(value.id(),value); + return Map.of("part_id",value.id(),"name",name,"block_count",positions.size()); + } + private Object pollChat(JsonObject request) { + String client=required(request,"client_id"); + if(chatClientId!=null&&!chatClientId.equals(client)&&!leased.isEmpty()) { + for(String id:List.copyOf(leased.keySet())) { + JsonObject old=messages.remove(id); + if(old!=null){Player p=Bukkit.getPlayer(UUID.fromString(old.get("playerId").getAsString()));if(p!=null)p.sendMessage("[Builder] Bridge restarted. Previous request stopped; check the world before resubmitting.");} + } + leased.clear();writesPaused=true; + for(OperationView v:engine.operations())if(!v.status().terminal())engine.cancel(v.id()); + } + chatClientId=client;lastPoll=System.currentTimeMillis();List pending=new ArrayList<>(); + for(var item:messages.entrySet())if(!leased.containsKey(item.getKey())){ + leased.put(item.getKey(),lastPoll);pending.add(item.getValue());if(pending.size()==8)break; + } + return Map.of("messages",pending); + } + private Object reply(JsonObject p) { + String id=required(p,"id"),player=required(p,"playerId");JsonObject source=messages.get(id); + if(source==null||!source.get("playerId").getAsString().equals(player))throw new Fault("permission_denied","Unknown or mismatched chat request"); + Player recipient=Bukkit.getPlayer(UUID.fromString(player));String text=str(p,"text",""); + if(recipient!=null&&!text.isBlank())recipient.sendMessage("[Builder] "+text.substring(0,Math.min(1600,text.length()))); + if(p.has("done")&&p.get("done").getAsBoolean()){messages.remove(id);leased.remove(id);} + return Map.of("delivered",recipient!=null); + } + private boolean command(CommandSender sender,Command cmd,String label,String[] args) { + try { + if(!(sender instanceof Player player)){sender.sendMessage("Use RPC for local automation, or /ai in game.");return true;} + if(!player.hasPermission("minecraftbuilder.use"))throw new Fault("permission_denied","Permission required"); + String sub=args.length==0?"status":args[0]; + if(sub.equals("setup")){ + if(!owner.isBlank()&&!owner.equals(player.getUniqueId().toString()))throw new Fault("permission_denied","Project already bound to another owner"); + owner=player.getUniqueId().toString();getConfig().set("owner-uuid",owner);saveConfig();player.sendMessage("[Builder] Project bound. Set area with /ai area minX minY minZ maxX maxY maxZ; configure Bridge using the private plugin config.");return true; + } + if(!player.getUniqueId().toString().equals(owner))throw new Fault("permission_denied","Run /ai setup as the project owner first"); + switch(sub){ + case "area" -> { + available(); + if(args.length==2&&args[1].equals("here")){ + Location l=player.getLocation();args=new String[]{"area",Integer.toString(l.getBlockX()-24),Integer.toString(Math.max(world.getMinHeight(),l.getBlockY()-1)),Integer.toString(l.getBlockZ()-24),Integer.toString(l.getBlockX()+24),Integer.toString(Math.min(world.getMaxHeight()-1,l.getBlockY()+48)),Integer.toString(l.getBlockZ()+24)}; + } + if(args.length!=7)throw new Fault("invalid_request","/ai area here OR /ai area minX minY minZ maxX maxY maxZ"); + if(engine.operations().stream().anyMatch(v->!v.status().terminal()))throw new Fault("busy","Stop active operations before changing the area"); + Region next=new Region(world.getUID().toString(),new BlockPos(Integer.parseInt(args[1]),Integer.parseInt(args[2]),Integer.parseInt(args[3])),new BlockPos(Integer.parseInt(args[4]),Integer.parseInt(args[5]),Integer.parseInt(args[6]))); + if(next.volume()>2_000_000||!player.getWorld().equals(world)||next.min().y()=world.getMaxHeight())throw new Fault("out_of_bounds","Area must be within the configured world and at most 2 million blocks"); + region=next;for(String side:List.of("min","max")){BlockPos at=side.equals("min")?region.min():region.max();getConfig().set("region."+side+".x",at.x());getConfig().set("region."+side+".y",at.y());getConfig().set("region."+side+".z",at.z());}saveConfig();player.sendMessage("[Builder] Area set."); + } + case "status" -> player.sendMessage("[Builder] project="+projectId+"; operations="+engine.operations().size()+"; bridge="+(System.currentTimeMillis()-lastPoll<15_000?"connected":"offline")+"; halted="+halted); + case "stop" -> { + writesPaused=true;for(OperationView v:engine.operations())if(!v.status().terminal())engine.cancel(v.id()); + messages.entrySet().removeIf(e->!leased.containsKey(e.getKey())); + enqueue(player,"","cancel");player.sendMessage("[Builder] Stopping future writes and agent turn. New writes remain paused until your next request."); + } + case "resume" -> {writesPaused=false;player.sendMessage("[Builder] New plans enabled; cancelled operations are not replayed.");} + case "protect" -> { + if(args.length!=2)throw new Fault("invalid_request","/ai protect part-id");Part old=parts.get(args[1]);if(old==null)throw new Fault("not_found","Part not found"); + parts.put(old.id(),new Part(old.id(),old.name(),old.operationId(),old.positions(),true));disk.submit(()->{try{saveMetadata();}catch(IOException e){halted=true;}});player.sendMessage("[Builder] Part protected."); + } + case "camera" -> { + if(args.length!=3||!args[1].equals("save"))throw new Fault("invalid_request","/ai camera save name"); + Location l=player.getLocation();if(!player.getWorld().equals(world)||!region.contains(new BlockPos(l.getBlockX(),l.getBlockY(),l.getBlockZ())))throw new Fault("out_of_bounds","Save a camera inside the configured world and project area"); + if(args[2].length()>64||cameras.size()>=64&&!cameras.containsKey(args[2]))throw new Fault("budget_exceeded","At most 64 cameras with names up to 64 characters"); + JsonObject pose=new JsonObject();pose.addProperty("camera_id",args[2]);pose.addProperty("x",l.getX());pose.addProperty("y",l.getY());pose.addProperty("z",l.getZ());pose.addProperty("yaw",l.getYaw());pose.addProperty("pitch",l.getPitch()); + cameras.put(args[2],pose);disk.submit(()->{try{saveMetadata();}catch(IOException e){halted=true;}});player.sendMessage("[Builder] Camera saved."); + } + default -> {if(System.currentTimeMillis()-lastPoll>15_000)throw new Fault("bridge_unavailable","Start the Bridge daemon first");enqueue(player,String.join(" ",args),"prompt");writesPaused=false;player.sendMessage("[Builder] Request queued.");} + } + }catch(Exception e){sender.sendMessage("[Builder] "+e.getMessage());}return true; + } + private void enqueue(Player player,String text,String type) { + if(messages.size()>=32&&!type.equals("cancel"))throw new Fault("busy","Chat queue is full"); + if(text.length()>4000)throw new Fault("budget_exceeded","Message too long"); + JsonObject value=new JsonObject();String id=UUID.randomUUID().toString();value.addProperty("id",id);value.addProperty("playerId",player.getUniqueId().toString());value.addProperty("projectId",projectId);value.addProperty("text",text);value.addProperty("type",type); + Location l=player.getLocation();value.add("playerPosition",json.toJsonTree(Map.of("x",l.getBlockX(),"y",l.getBlockY(),"z",l.getBlockZ(),"yaw",l.getYaw(),"pitch",l.getPitch())));value.addProperty("worldId",player.getWorld().getUID().toString()); + org.bukkit.block.Block target=player.getTargetBlockExact(64); + if(target!=null)value.add("lookTarget",json.toJsonTree(Map.of("x",target.getX(),"y",target.getY(),"z",target.getZ()))); + messages.put(id,value); + } + private Object capture(JsonObject p)throws Exception { + if(p.has("capture_id")){ + String id=required(p,"capture_id");if(!main(()->captureIds.contains(id)))throw new Fault("permission_denied","Unknown capture"); + JsonObject result=cameraRequest("GET","/v1/captures/"+id,null); + if(!str(result,"status","pending").equals("pending"))main(()->{if(id.equals(activeCaptureId)){cameraBusyUntil=0;activeCaptureId=null;}return null;}); + return result; + } + JsonObject pose=main(()->{ + if(p.has("after_operation_id")&&engine.status(required(p,"after_operation_id")).status()!=OperationStatus.APPLIED)throw new Fault("capture_not_ready","Operation not yet applied"); + JsonObject result=p.has("pose")?p.getAsJsonObject("pose").deepCopy():cameras.get(required(p,"camera_id")); + if(result==null)throw new Fault("not_found","Camera not found");result=result.deepCopy(); + result.remove("camera_id"); + for(String key:result.keySet())if(!Set.of("x","y","z","yaw","pitch","fov","width","height").contains(key))throw new Fault("invalid_request","Unknown camera field: "+key); + double yaw=number(result,"yaw"),pitch=number(result,"pitch"); + if(yaw < -360 || yaw > 360 || pitch < -90 || pitch > 90)throw new Fault("invalid_request","Camera yaw/pitch out of range"); + if(result.has("fov")&&(integer(result,"fov")<30||integer(result,"fov")>110))throw new Fault("invalid_request","FOV must be an integer 30..110"); + if(result.has("width")&&(integer(result,"width")<320||integer(result,"width")>1920)||result.has("height")&&(integer(result,"height")<180||integer(result,"height")>1080))throw new Fault("invalid_request","Capture dimensions out of range"); + double x=number(result,"x"),y=number(result,"y"),z=number(result,"z"); + if(!region.contains(new BlockPos((int)Math.floor(x),(int)Math.floor(y),(int)Math.floor(z))))throw new Fault("out_of_bounds","Camera exceeds project area"); + result.addProperty("dimension",world.getKey().asString());if(p.has("after_operation_id"))result.addProperty("afterOperationId",required(p,"after_operation_id"));return result; + }); + CompletableFuture teleport=main(()->{ + String uuid=getConfig().getString("camera-player-uuid","");if(uuid.isBlank())throw new Fault("camera_unavailable","Configure a spectator camera account"); + Player camera=Bukkit.getPlayer(UUID.fromString(uuid));if(camera==null||camera.getGameMode()!=GameMode.SPECTATOR)throw new Fault("camera_unavailable","Configured spectator camera must be online"); + if(cameraBusyUntil>System.currentTimeMillis())throw new Fault("busy","A capture is already in progress; poll it before moving the camera"); + cameraBusyUntil=System.currentTimeMillis()+45_000; + return camera.teleportAsync(new Location(world,number(pose,"x"),number(pose,"y"),number(pose,"z"),(float)number(pose,"yaw"),(float)number(pose,"pitch"))); + }); + try{ + if(!teleport.get(15,TimeUnit.SECONDS))throw new Fault("camera_unavailable","Camera teleport was rejected"); + JsonObject response=cameraRequest("POST","/v1/capture",pose); + if(response.has("captureId"))main(()->{if(captureIds.size()>256)captureIds.clear();activeCaptureId=response.get("captureId").getAsString();captureIds.add(activeCaptureId);return null;}); + return response; + }catch(Exception e){main(()->{cameraBusyUntil=0;activeCaptureId=null;return null;});throw e;} + } + private JsonObject cameraRequest(String method,String path,JsonObject body)throws Exception { + HttpRequest.Builder request=HttpRequest.newBuilder(URI.create("http://127.0.0.1:"+getConfig().getInt("camera-port",8766)+path)).timeout(Duration.ofSeconds(25)).header("Authorization","Bearer "+getConfig().getString("camera-token")).header("Content-Type","application/json"); + if(method.equals("POST"))request.POST(HttpRequest.BodyPublishers.ofString(json.toJson(body)));else request.GET(); + HttpResponse response=cameraHttp.send(request.build(),HttpResponse.BodyHandlers.ofInputStream()); + try(InputStream in=response.body()){ + byte[] bytes=in.readNBytes(12*1024*1024+1);if(bytes.length>12*1024*1024)throw new Fault("budget_exceeded","Camera response too large"); + if(response.statusCode()>=400)throw new Fault("camera_unavailable","Camera service returned HTTP "+response.statusCode()); + return JsonParser.parseString(new String(bytes,StandardCharsets.UTF_8)).getAsJsonObject(); + } + } + private synchronized void saveMetadata()throws IOException { + JsonObject data; + try{data=main(()->{JsonObject value=new JsonObject();value.add("parts",json.toJsonTree(parts.values()));value.add("cameras",json.toJsonTree(cameras));return value;});} + catch(Exception e){throw new IOException("Cannot snapshot metadata",e);} + Path path=getDataFolder().toPath().resolve("metadata.json"),temp=path.resolveSibling("metadata.tmp"); + Files.writeString(temp,json.toJson(data));Files.move(temp,path,StandardCopyOption.ATOMIC_MOVE,StandardCopyOption.REPLACE_EXISTING); + } + private void loadMetadata()throws IOException { + Path path=getDataFolder().toPath().resolve("metadata.json");if(!Files.exists(path))return; + JsonObject data=JsonParser.parseString(Files.readString(path)).getAsJsonObject(); + for(JsonElement e:data.getAsJsonArray("parts")){Part part=json.fromJson(e,Part.class);parts.put(part.id(),part);} + for(var e:data.getAsJsonObject("cameras").entrySet())cameras.put(e.getKey(),e.getValue().getAsJsonObject()); + } + private static String required(JsonObject p,String key){String value=str(p,key,"");if(value.isBlank()||value.length()>256)throw new Fault("invalid_request","Required bounded string: "+key);return value;} + private static String str(JsonObject p,String key,String fallback){return p.has(key)&&!p.get(key).isJsonNull()?p.get(key).getAsString():fallback;} + private static BlockPos pos(JsonObject p){return new BlockPos(integer(p,"x"),integer(p,"y"),integer(p,"z"));} + private Region bounds(Set positions){ + return new Region(world.getUID().toString(),new BlockPos(positions.stream().mapToInt(BlockPos::x).min().orElseThrow(),positions.stream().mapToInt(BlockPos::y).min().orElseThrow(),positions.stream().mapToInt(BlockPos::z).min().orElseThrow()),new BlockPos(positions.stream().mapToInt(BlockPos::x).max().orElseThrow(),positions.stream().mapToInt(BlockPos::y).max().orElseThrow(),positions.stream().mapToInt(BlockPos::z).max().orElseThrow())); + } + private static int integer(JsonObject p,String key){try{return p.get(key).getAsBigDecimal().intValueExact();}catch(Exception e){throw new Fault("invalid_request","Integer coordinate required: "+key);}} + private static double number(JsonObject p,String key){double v=p.get(key).getAsDouble();if(!Double.isFinite(v))throw new Fault("invalid_request","Finite number required");return v;} +} diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuildingWorld.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuildingWorld.java new file mode 100644 index 0000000..a8f9de5 --- /dev/null +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/BuildingWorld.java @@ -0,0 +1,52 @@ +package io.github.minecraftbuilder.paper; + +import io.github.minecraftbuilder.core.*; +import org.bukkit.*; +import org.bukkit.block.data.BlockData; +import java.util.*; + +/** Small tested policy; unsupported existing contents are protected too. */ +final class BuildingWorld implements WorldAccess, BlockPolicy { + private final World world; + private final Map parsed = new HashMap<>(); + private static final Set MATERIALS = Set.of("air", "stone", "cobblestone", "mossy_cobblestone", + "stone_bricks", "mossy_stone_bricks", "cracked_stone_bricks", "chiseled_stone_bricks", "smooth_stone", + "granite", "polished_granite", "diorite", "polished_diorite", "andesite", "polished_andesite", + "deepslate", "cobbled_deepslate", "polished_deepslate", "deepslate_bricks", "deepslate_tiles", + "bricks", "quartz_block", "quartz_pillar", "smooth_quartz", "sandstone", "cut_sandstone", "smooth_sandstone", + "red_sandstone", "terracotta", "white_terracotta", "black_terracotta", "orange_terracotta", + "white_concrete", "gray_concrete", "black_concrete", "glass", "tinted_glass", "obsidian", + "dirt", "grass_block", "bedrock", "oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks", + "oak_log", "spruce_log", "birch_log", "dark_oak_log", "stripped_oak_log", "stripped_spruce_log", + "stone_brick_stairs", "cobblestone_stairs", "oak_stairs", "spruce_stairs", "deepslate_tile_stairs", + "stone_brick_slab", "cobblestone_slab", "oak_slab", "spruce_slab", "smooth_stone_slab"); + BuildingWorld(World world) { this.world = world; } + static List supportedMaterials() { return MATERIALS.stream().sorted().map(s->"minecraft:"+s).toList(); } + BlockData data(String state) { return parsed.computeIfAbsent(state, Bukkit::createBlockData).clone(); } + String canonical(String state) { if (!supports(state)) throw new RpcServer.Fault("unsupported_block", "Unsupported block: " + state); return data(state).getAsString(); } + public boolean supports(String state) { + try { + BlockData data = data(state); + return MATERIALS.contains(data.getMaterial().getKey().getKey()) + && !(data instanceof org.bukkit.block.data.Waterlogged w && w.isWaterlogged()); + } catch (IllegalArgumentException e) { return false; } + } + private void ready(BlockPos p) { + if (!Bukkit.isPrimaryThread()) throw new IllegalStateException("World access outside server thread"); + if (p.y() < world.getMinHeight() || p.y() >= world.getMaxHeight()) throw new RpcServer.Fault("out_of_bounds", "Position exceeds world height"); + if (!world.isChunkLoaded(p.x() >> 4, p.z() >> 4)) throw new RpcServer.Fault("chunk_not_loaded", "Visit/load the target chunks before editing"); + if (!world.getWorldBorder().isInside(new Location(world,p.x()+0.5,p.y(),p.z()+0.5))) throw new RpcServer.Fault("out_of_bounds", "Position exceeds world border"); + } + public String getBlock(BlockPos p) { ready(p); return world.getBlockAt(p.x(),p.y(),p.z()).getBlockData().getAsString(); } + public void setBlock(BlockPos p, String state) { + ready(p); + // A neighbour may have changed since prepare. Never remove supports next to + // dynamic/unsupported blocks merely because the target itself still matches. + for (BlockPos d : List.of(new BlockPos(1,0,0),new BlockPos(-1,0,0),new BlockPos(0,1,0),new BlockPos(0,-1,0),new BlockPos(0,0,1),new BlockPos(0,0,-1))) { + BlockPos n=p.add(d); + if(n.y()>=world.getMinHeight()&&n.y()(32), + r -> { Thread t = new Thread(r, "mcb-rpc"); t.setDaemon(true); return t; }, new ThreadPoolExecutor.AbortPolicy()); + server.setExecutor(executor); + server.createContext("/health", exchange -> { + if (!exchange.getRequestMethod().equals("GET") || !exchange.getRequestURI().getPath().equals("/health")) { + respond(exchange, 404, Map.of("error", "not_found")); return; + } + respond(exchange, 200, Map.of("service", "minecraft-builder-mcp", "version", "0.1.0", "status", "ready")); + }); + server.createContext("/v1/rpc", exchange -> { + try { + if (!exchange.getRequestURI().getPath().equals("/v1/rpc") || !exchange.getRequestMethod().equals("POST")) { + respond(exchange, 405, Map.of("ok",false,"error", Map.of("code","method_not_allowed","message","POST /v1/rpc required"))); return; + } + if (exchange.getRequestHeaders().containsKey("Origin")) throw new Fault("permission_denied", "Browser origins are not permitted"); + String authorization = exchange.getRequestHeaders().getFirst("Authorization"); + boolean admin = equal(authorization, "Bearer " + adminToken); + if (!admin && !equal(authorization, "Bearer " + agentToken)) throw new Fault("permission_denied", "Invalid capability token"); + byte[] bytes = exchange.getRequestBody().readNBytes(MAX_BODY + 1); + if (bytes.length > MAX_BODY) throw new Fault("budget_exceeded", "Request exceeds 1 MiB"); + JsonObject request = JsonParser.parseString(new String(bytes, StandardCharsets.UTF_8)).getAsJsonObject(); + String method = request.get("method").getAsString(); + if (method.startsWith("chat_") && !admin) throw new Fault("permission_denied", "Chat routing requires the administrator capability"); + JsonObject params = request.has("params") ? request.getAsJsonObject("params") : new JsonObject(); + Object result = handler.call(method, params, admin); + respond(exchange, 200, Map.of("ok", true, "result", result)); + } catch (Exception failure) { + Throwable e = failure; + while ((e instanceof ExecutionException || e instanceof CompletionException) && e.getCause() != null) e = e.getCause(); + String code = e instanceof Fault f ? f.code : e instanceof TimeoutException ? "timeout" : "invalid_request"; + String message = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + respond(exchange, code.equals("permission_denied") ? 403 : 400, + Map.of("ok", false, "error", Map.of("code", code, "message", message.substring(0, Math.min(600, message.length()))))); + } + }); + server.start(); + } + static boolean equal(String actual, String expected) { + return actual != null && MessageDigest.isEqual(actual.getBytes(StandardCharsets.UTF_8), expected.getBytes(StandardCharsets.UTF_8)); + } + private static void respond(HttpExchange e, int status, Object value) throws IOException { + byte[] bytes = JSON.toJson(value).getBytes(StandardCharsets.UTF_8); + e.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8"); + e.getResponseHeaders().set("Cache-Control", "no-store"); + e.sendResponseHeaders(status, bytes.length); + try (OutputStream out = e.getResponseBody()) { out.write(bytes); } + finally { e.close(); } + } + public int port() { return server.getAddress().getPort(); } + public void close() { server.stop(0); executor.shutdownNow(); } +} diff --git a/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/SchematicAssets.java b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/SchematicAssets.java new file mode 100644 index 0000000..ff638eb --- /dev/null +++ b/paper-plugin/src/main/java/io/github/minecraftbuilder/paper/SchematicAssets.java @@ -0,0 +1,397 @@ +package io.github.minecraftbuilder.paper; + +import io.github.minecraftbuilder.core.BlockPos; +import java.io.*; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.*; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.*; +import java.util.zip.GZIPInputStream; +import java.util.zip.GZIPOutputStream; + +/** + * Strict, dependency-free subset of Sponge Schematic v2 (gzip, big-endian NBT, palette varints). + * Spec: https://github.com/SpongePowered/Schematic-Specification/blob/master/versions/schematic-2.md + * IO methods run off the world thread. Snapshot input must already exclude all unsupported world data. + * This codec deliberately rejects entities, block entities, biomes, unknown top-level tags and versions. + * No DataFixer conversion is attempted: returned states must be canonicalized/validated by the server. + */ +public final class SchematicAssets { + public static final int MAX_BLOCKS = 4096, MAX_ASSETS = 64; + private static final int MAX_COMPRESSED = 1_048_576, MAX_NBT = 4_194_304; + private static final Set ROOT_FIELDS = Set.of("Version", "DataVersion", "Width", "Height", "Length", + "Offset", "PaletteMax", "Palette", "BlockData", "BlockEntities", "Entities", "Metadata"); + // Deliberately mirrors the prototype's small server policy. The server validates states again on import. + private static final Set MATERIALS = Set.of("air", "stone", "cobblestone", "mossy_cobblestone", + "stone_bricks", "mossy_stone_bricks", "cracked_stone_bricks", "chiseled_stone_bricks", "smooth_stone", + "granite", "polished_granite", "diorite", "polished_diorite", "andesite", "polished_andesite", + "deepslate", "cobbled_deepslate", "polished_deepslate", "deepslate_bricks", "deepslate_tiles", + "bricks", "quartz_block", "quartz_pillar", "smooth_quartz", "sandstone", "cut_sandstone", "smooth_sandstone", + "red_sandstone", "terracotta", "white_terracotta", "black_terracotta", "orange_terracotta", + "white_concrete", "gray_concrete", "black_concrete", "glass", "tinted_glass", "obsidian", "dirt", + "grass_block", "bedrock", "oak_planks", "spruce_planks", "birch_planks", "dark_oak_planks", + "oak_log", "spruce_log", "birch_log", "dark_oak_log", "stripped_oak_log", "stripped_spruce_log", + "stone_brick_stairs", "cobblestone_stairs", "oak_stairs", "spruce_stairs", "deepslate_tile_stairs", + "stone_brick_slab", "cobblestone_slab", "oak_slab", "spruce_slab", "smooth_stone_slab"); + private final Path root; + + public record Asset(String assetId, String name, int width, int height, int length, int blockCount, + int dataVersion, BlockPos offset, String sha256, long bytes) { } + private record Decoded(String name, int width, int height, int length, int dataVersion, + BlockPos offset, List blocks) { } + private record Tag(int type, Object value) { } + private record TagList(int elementType, List values) { } + + public SchematicAssets(Path root) throws IOException { + this.root = root.toAbsolutePath().normalize(); + rejectSymlinkParents(); + Files.createDirectories(this.root); + if (!Files.isDirectory(this.root, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Asset root must be a directory"); + } + + /** Export all cells of a dense box. origin is the clipboard anchor, not necessarily the minimum. */ + public synchronized Asset exportSnapshot(String name, Map blocks, BlockPos origin, int dataVersion) throws IOException { + requireName(name); + Objects.requireNonNull(blocks); Objects.requireNonNull(origin); + if (blocks.isEmpty() || blocks.size() > MAX_BLOCKS) throw new IOException("Snapshot must contain 1..4096 blocks"); + if (dataVersion < 0) throw new IOException("Invalid Minecraft DataVersion"); + rejectSymlinkParents(); + if (assetPaths().size() >= MAX_ASSETS) throw new IOException("Asset library is full (64 files maximum)"); + int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE, minZ = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE, maxZ = Integer.MIN_VALUE; + for (BlockPos at : blocks.keySet()) { + if (at == null) throw new IOException("Null snapshot position"); + minX = Math.min(minX, at.x()); minY = Math.min(minY, at.y()); minZ = Math.min(minZ, at.z()); + maxX = Math.max(maxX, at.x()); maxY = Math.max(maxY, at.y()); maxZ = Math.max(maxZ, at.z()); + } + int width = extent(minX, maxX), height = extent(minY, maxY), length = extent(minZ, maxZ); + int volume = volume(width, height, length); + if (blocks.size() != volume) throw new IOException("Snapshot must include every bounding-box cell, including air"); + BlockPos offset; + try { offset = new BlockPos(Math.subtractExact(minX, origin.x()), Math.subtractExact(minY, origin.y()), Math.subtractExact(minZ, origin.z())); } + catch (ArithmeticException e) { throw new IOException("Clipboard offset overflows integer coordinates", e); } + LinkedHashMap palette = new LinkedHashMap<>(); + ByteArrayOutputStream data = new ByteArrayOutputStream(); + for (int y = 0; y < height; y++) for (int z = 0; z < length; z++) for (int x = 0; x < width; x++) { + String state = rotateState(blocks.get(new BlockPos(minX + x, minY + y, minZ + z)), 0); + int index = palette.computeIfAbsent(state, ignored -> palette.size()); + writeVarInt(data, index); + } + byte[] encoded = encode(name, dataVersion, width, height, length, offset, palette, data.toByteArray()); + if (encoded.length > MAX_COMPRESSED) throw new IOException("Compressed asset exceeds 1 MiB"); + String id = UUID.randomUUID().toString(); + Path destination = path(id), temporary = Files.createTempFile(root, ".asset-", ".tmp"); + try { + Files.write(temporary, encoded, StandardOpenOption.TRUNCATE_EXISTING); + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { channel.force(true); } + Files.move(temporary, destination, StandardCopyOption.ATOMIC_MOVE); + try (FileChannel directory = FileChannel.open(root, StandardOpenOption.READ)) { directory.force(true); } + } finally { Files.deleteIfExists(temporary); } + return metadata(id, decode(encoded), encoded); + } + + /** Imported files may be provisioned locally as [A-Za-z0-9][A-Za-z0-9_-]{0,63}.schem. */ + public synchronized List list() throws IOException { + List result = new ArrayList<>(); + for (Path file : assetPaths()) { + String id = file.getFileName().toString().replaceFirst("\\.schem$", ""); + byte[] bytes = load(id); + result.add(metadata(id, decode(bytes), bytes)); + } + return List.copyOf(result); + } + + /** Bounded strict inspection of one asset without exposing a filesystem path. */ + public synchronized Asset metadata(String assetId) throws IOException { + byte[] bytes = load(assetId); + return metadata(assetId, decode(bytes), bytes); + } + + /** rotation90 is degrees: 0/90/180/270 clockwise viewed from above. Rotate about target anchor. */ + public synchronized Map read(String assetId, BlockPos target, int rotation90) throws IOException { + return read(assetId, target, rotation90, Integer.MAX_VALUE); + } + + /** Checks the same decoded input used for placement, even if a file changed after metadata(). */ + public synchronized Map read(String assetId, BlockPos target, int rotation90, int maxDataVersion) throws IOException { + if (!Set.of(0, 90, 180, 270).contains(rotation90)) throw new IOException("Rotation must be 0, 90, 180 or 270 degrees"); + if (maxDataVersion < 0) throw new IOException("Invalid target Minecraft DataVersion"); + Objects.requireNonNull(target); + Decoded value = decode(load(assetId)); + if (value.dataVersion > maxDataVersion) throw new IOException("Schematic DataVersion is newer than the target server"); + Map result = new LinkedHashMap<>(); + try { + int index = 0; + for (int y = 0; y < value.height; y++) for (int z = 0; z < value.length; z++) for (int x = 0; x < value.width; x++) { + int dx = Math.addExact(x, value.offset.x()), dy = Math.addExact(y, value.offset.y()), dz = Math.addExact(z, value.offset.z()); + for (int turn = 0; turn < rotation90 / 90; turn++) { int oldX = dx; dx = Math.negateExact(dz); dz = oldX; } + BlockPos at = target.add(new BlockPos(dx, dy, dz)); + result.put(at, rotateState(value.blocks.get(index++), rotation90)); + } + } catch (ArithmeticException e) { throw new IOException("Placement overflows integer coordinates", e); } + return Collections.unmodifiableMap(result); + } + + private static Asset metadata(String id, Decoded data, byte[] bytes) { + try { + return new Asset(id, data.name, data.width, data.height, data.length, data.blocks.size(), data.dataVersion, + data.offset, HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(bytes)), bytes.length); + } catch (NoSuchAlgorithmException e) { throw new AssertionError(e); } + } + private Path path(String id) throws IOException { + if (id == null || !id.matches("[A-Za-z0-9][A-Za-z0-9_-]{0,63}")) throw new IOException("Invalid asset ID; paths are not accepted"); + return root.resolve(id + ".schem"); + } + private void rejectSymlinkParents() throws IOException { + for (Path at = root; at != null; at = at.getParent()) + if (Files.isSymbolicLink(at)) throw new IOException("Symlinks are not allowed in asset directory path"); + } + private List assetPaths() throws IOException { + rejectSymlinkParents(); + List files = new ArrayList<>(); + try (DirectoryStream stream = Files.newDirectoryStream(root, "*.schem")) { + for (Path file : stream) { + String name = file.getFileName().toString(); + path(name.substring(0, name.length() - 6)); + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Asset must be a regular non-symlink file"); + if (files.size() >= MAX_ASSETS) throw new IOException("Asset library exceeds 64 files"); + files.add(file); + } + } + files.sort(Comparator.comparing(file -> file.getFileName().toString())); + return files; + } + private byte[] load(String id) throws IOException { + rejectSymlinkParents(); + Path file = path(id); + if (!Files.isRegularFile(file, LinkOption.NOFOLLOW_LINKS)) throw new IOException("Asset not found or is not a regular file"); + try (InputStream in = Files.newInputStream(file, StandardOpenOption.READ, LinkOption.NOFOLLOW_LINKS)) { + byte[] bytes = in.readNBytes(MAX_COMPRESSED + 1); + if (bytes.length > MAX_COMPRESSED) throw new IOException("Compressed asset exceeds 1 MiB"); + return bytes; + } + } + private static int extent(int min, int max) throws IOException { + long value = (long) max - min + 1; + if (value < 1 || value > MAX_BLOCKS) throw new IOException("Schematic dimension exceeds limits"); + return (int) value; + } + private static int volume(int width, int height, int length) throws IOException { + long volume = (long) width * height * length; + if (width < 1 || height < 1 || length < 1 || volume > MAX_BLOCKS) throw new IOException("Schematic volume must be 1..4096"); + return (int) volume; + } + private static void requireName(String name) throws IOException { + if (name == null || name.isBlank() || name.length() > 64 || name.chars().anyMatch(Character::isISOControl)) + throw new IOException("Asset name must contain 1..64 printable characters"); + } + + /** Only recognised schemas rotate: no guessing about unknown direction-like properties. */ + static String rotateState(String state, int degrees) throws IOException { + if (state == null || state.length() > 512 || !state.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?")) + throw new IOException("Invalid vanilla block state"); + int bracket = state.indexOf('['); + String id = state.substring(10, bracket < 0 ? state.length() : bracket); + if (!MATERIALS.contains(id)) throw new IOException("Unsupported schematic block: minecraft:" + id); + TreeMap properties = new TreeMap<>(); + if (bracket >= 0) for (String property : state.substring(bracket + 1, state.length() - 1).split(",")) { + String[] pair = property.split("=", -1); + if (pair.length != 2 || properties.put(pair[0], pair[1]) != null) throw new IOException("Invalid or duplicate block property"); + } + Set allowed = id.endsWith("_stairs") ? Set.of("facing", "half", "shape", "waterlogged") + : id.endsWith("_slab") ? Set.of("type", "waterlogged") + : id.endsWith("_log") || id.equals("quartz_pillar") || id.equals("deepslate") ? Set.of("axis") + : id.equals("grass_block") ? Set.of("snowy") : Set.of(); + if (!allowed.containsAll(properties.keySet())) throw new IOException("Unsupported block properties for minecraft:" + id); + for (var property : properties.entrySet()) { + Set values = switch (property.getKey()) { + case "facing" -> Set.of("north", "east", "south", "west"); + case "axis" -> Set.of("x", "y", "z"); + case "half" -> Set.of("top", "bottom"); + case "shape" -> Set.of("straight", "inner_left", "inner_right", "outer_left", "outer_right"); + case "type" -> Set.of("top", "bottom", "double"); + case "waterlogged" -> Set.of("false"); + case "snowy" -> Set.of("true", "false"); + default -> Set.of(); + }; + if (!values.contains(property.getValue())) throw new IOException("Unsupported block property value"); + } + if (degrees != 0 && id.endsWith("_stairs") && !properties.containsKey("facing")) + throw new IOException("Rotation requires explicit stairs facing"); + if (degrees != 0 && allowed.contains("axis") && !properties.containsKey("axis")) + throw new IOException("Rotation requires explicit block axis"); + if (properties.containsKey("facing")) { + List faces = List.of("north", "east", "south", "west"); + properties.put("facing", faces.get((faces.indexOf(properties.get("facing")) + degrees / 90) % 4)); + } + if (degrees % 180 != 0 && properties.containsKey("axis") && !properties.get("axis").equals("y")) + properties.put("axis", properties.get("axis").equals("x") ? "z" : "x"); + return "minecraft:" + id + (properties.isEmpty() ? "" : "[" + String.join(",", properties.entrySet().stream().map(e -> e.getKey() + "=" + e.getValue()).toList()) + "]"); + } + + private static byte[] encode(String name, int version, int width, int height, int length, BlockPos offset, + LinkedHashMap palette, byte[] blockData) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(new GZIPOutputStream(bytes))) { + header(out, 10, "Schematic"); + header(out, 3, "Version"); out.writeInt(2); + header(out, 3, "DataVersion"); out.writeInt(version); + header(out, 2, "Width"); out.writeShort(width); + header(out, 2, "Height"); out.writeShort(height); + header(out, 2, "Length"); out.writeShort(length); + header(out, 11, "Offset"); out.writeInt(3); out.writeInt(offset.x()); out.writeInt(offset.y()); out.writeInt(offset.z()); + header(out, 3, "PaletteMax"); out.writeInt(palette.size()); + header(out, 10, "Palette"); + for (var entry : palette.entrySet()) { header(out, 3, entry.getKey()); out.writeInt(entry.getValue()); } + out.writeByte(0); + header(out, 7, "BlockData"); out.writeInt(blockData.length); out.write(blockData); + header(out, 9, "BlockEntities"); out.writeByte(10); out.writeInt(0); + header(out, 9, "Entities"); out.writeByte(10); out.writeInt(0); + header(out, 10, "Metadata"); header(out, 8, "Name"); out.writeUTF(name); + header(out, 8, "Author"); out.writeUTF("minecraft-builder-mcp"); + header(out, 4, "Date"); out.writeLong(System.currentTimeMillis()); + out.writeByte(0); out.writeByte(0); + } + return bytes.toByteArray(); + } + private static void header(DataOutputStream out, int type, String name) throws IOException { out.writeByte(type); out.writeUTF(name); } + private static void writeVarInt(OutputStream out, int value) throws IOException { + do { int next = value & 127; value >>>= 7; out.write(next | (value != 0 ? 128 : 0)); } while (value != 0); + } + private static Decoded decode(byte[] compressed) throws IOException { + byte[] raw; + try (GZIPInputStream gzip = new GZIPInputStream(new ByteArrayInputStream(compressed))) { + raw = gzip.readNBytes(MAX_NBT + 1); + if (raw.length > MAX_NBT) throw new IOException("Inflated schematic exceeds 4 MiB"); + } + try (DataInputStream in = new DataInputStream(new ByteArrayInputStream(raw))) { + if (in.readUnsignedByte() != 10) throw new IOException("Schematic root must be TAG_Compound"); + String rootName = boundedUtf(in); + if (!rootName.isEmpty() && !rootName.equals("Schematic")) throw new IOException("Unexpected schematic root name"); + Map tags = compound(in, 0, new int[]{0}); + if (in.read() != -1) throw new IOException("Trailing NBT data is not accepted"); + if (!ROOT_FIELDS.containsAll(tags.keySet())) throw new IOException("Unsupported schematic fields (biomes/custom data are not imported)"); + if (integer(tags, "Version") != 2) throw new IOException("Only Sponge schematic version 2 is supported"); + int dataVersion = integer(tags, "DataVersion"); + if (dataVersion < 0) throw new IOException("Invalid Minecraft DataVersion"); + int width = Short.toUnsignedInt((Short) required(tags, "Width", 2).value); + int height = Short.toUnsignedInt((Short) required(tags, "Height", 2).value); + int length = Short.toUnsignedInt((Short) required(tags, "Length", 2).value); + int volume = volume(width, height, length); + BlockPos offset = new BlockPos(0, 0, 0); + if (tags.containsKey("Offset")) { + int[] values = (int[]) required(tags, "Offset", 11).value; + if (values.length != 3) throw new IOException("Offset must have exactly 3 integers"); + offset = new BlockPos(values[0], values[1], values[2]); + } + for (String name : List.of("Entities", "BlockEntities")) if (tags.containsKey(name)) { + TagList list = (TagList) required(tags, name, 9).value; + if (!list.values.isEmpty()) throw new IOException(name + " are unsupported; import rejected without dropping data"); + if (list.elementType != 0 && list.elementType != 10) throw new IOException("Invalid " + name + " list type"); + } + Map paletteTags = map(required(tags, "Palette", 10)); + if (paletteTags.isEmpty() || paletteTags.size() > MAX_BLOCKS) throw new IOException("Invalid palette size"); + int paletteMax = tags.containsKey("PaletteMax") ? integer(tags, "PaletteMax") : MAX_BLOCKS; + if (paletteMax < 1 || paletteMax > MAX_BLOCKS) throw new IOException("Invalid PaletteMax"); + Map palette = new HashMap<>(); + for (var entry : paletteTags.entrySet()) { + if (entry.getValue().type != 3) throw new IOException("Palette indices must be integers"); + int id = (Integer) entry.getValue().value; + String state = rotateState(entry.getKey(), 0); + if (id < 0 || id >= paletteMax || palette.put(id, state) != null) throw new IOException("Invalid or duplicate palette index"); + } + byte[] blockData = (byte[]) required(tags, "BlockData", 7).value; + ByteArrayInputStream data = new ByteArrayInputStream(blockData); + List blocks = new ArrayList<>(volume); + for (int i = 0; i < volume; i++) { + int id = readVarInt(data); + String state = palette.get(id); + if (state == null) throw new IOException("BlockData refers to missing palette index"); + blocks.add(state); + } + if (data.read() != -1) throw new IOException("BlockData has extra entries"); + String name = "Imported schematic"; + if (tags.containsKey("Metadata")) { + Map metadata = map(required(tags, "Metadata", 10)); + if (metadata.containsKey("RequiredMods")) { + TagList mods = (TagList) required(metadata, "RequiredMods", 9).value; + if (!mods.values.isEmpty()) throw new IOException("Schematics requiring mods are unsupported"); + } + if (metadata.containsKey("Name")) { name = (String) required(metadata, "Name", 8).value; requireName(name); } + } + return new Decoded(name, width, height, length, dataVersion, offset, List.copyOf(blocks)); + } catch (IllegalArgumentException | ClassCastException e) { throw new IOException("Malformed schematic", e); } + } + private static int readVarInt(InputStream in) throws IOException { + int value = 0; + for (int index = 0; index < 5; index++) { + int next = in.read(); + if (next < 0) throw new EOFException("Truncated block-data varint"); + if (index == 4 && (next & 0xf0) != 0) throw new IOException("Block-data varint exceeds positive int"); + value |= (next & 127) << (index * 7); + if ((next & 128) == 0) { + if (value < 0 || index > 0 && (next & 127) == 0) throw new IOException("Negative or noncanonical block-data varint"); + return value; + } + } + throw new IOException("Block-data varint is too long"); + } + private static Tag required(Map tags, String key, int type) throws IOException { + Tag value = tags.get(key); + if (value == null || value.type != type) throw new IOException("Missing or mistyped schematic field: " + key); + return value; + } + private static int integer(Map tags, String key) throws IOException { return (Integer) required(tags, key, 3).value; } + @SuppressWarnings("unchecked") private static Map map(Tag tag) { return (Map) tag.value; } + private static String boundedUtf(DataInputStream in) throws IOException { + int length = in.readUnsignedShort(); + if (length > 4096) throw new IOException("NBT string exceeds 4096 bytes"); + byte[] bytes = in.readNBytes(length); + if (bytes.length != length) throw new EOFException("Truncated NBT string"); + // NBT Java strings use DataInput modified UTF-8, including supplementary characters. + ByteArrayOutputStream framed = new ByteArrayOutputStream(length + 2); + DataOutputStream out = new DataOutputStream(framed); out.writeShort(length); out.write(bytes); + return new DataInputStream(new ByteArrayInputStream(framed.toByteArray())).readUTF(); + } + private static Map compound(DataInputStream in, int depth, int[] nodes) throws IOException { + if (depth > 16) throw new IOException("NBT nesting exceeds 16 levels"); + Map result = new LinkedHashMap<>(); + while (true) { + int type = in.readUnsignedByte(); + if (type == 0) return result; + String name = boundedUtf(in); + if (result.containsKey(name)) throw new IOException("Duplicate NBT tag name"); + result.put(name, payload(in, type, depth + 1, nodes)); + } + } + private static int count(DataInputStream in, int itemBytes) throws IOException { + int count = in.readInt(); + if (count < 0 || count > MAX_NBT / itemBytes || (long) count * itemBytes > in.available()) + throw new IOException("NBT array/list size exceeds remaining bounded input"); + return count; + } + private static Tag payload(DataInputStream in, int type, int depth, int[] nodes) throws IOException { + if (depth > 16 || ++nodes[0] > 20_000) throw new IOException("NBT structure exceeds depth/node budget"); + Object value = switch (type) { + case 1 -> in.readByte(); case 2 -> in.readShort(); case 3 -> in.readInt(); case 4 -> in.readLong(); + case 5 -> in.readFloat(); case 6 -> in.readDouble(); + case 7 -> { int size = count(in, 1); byte[] bytes = in.readNBytes(size); if (bytes.length != size) throw new EOFException(); yield bytes; } + case 8 -> boundedUtf(in); + case 9 -> { + int elementType = in.readUnsignedByte(), size = count(in, 1); + if (elementType < 0 || elementType > 12 || (elementType == 0 && size != 0)) throw new IOException("Invalid NBT list type"); + if (size > 20_000) throw new IOException("NBT list exceeds node budget"); + List values = new ArrayList<>(size); + for (int i = 0; i < size; i++) values.add(payload(in, elementType, depth + 1, nodes)); + yield new TagList(elementType, List.copyOf(values)); + } + case 10 -> compound(in, depth, nodes); + case 11 -> { int size = count(in, 4); int[] values = new int[size]; for (int i = 0; i < size; i++) values[i] = in.readInt(); yield values; } + case 12 -> { int size = count(in, 8); long[] values = new long[size]; for (int i = 0; i < size; i++) values[i] = in.readLong(); yield values; } + default -> throw new IOException("Unknown NBT tag type"); + }; + return new Tag(type, value); + } +} diff --git a/paper-plugin/src/main/resources/config.yml b/paper-plugin/src/main/resources/config.yml new file mode 100644 index 0000000..c08186c --- /dev/null +++ b/paper-plugin/src/main/resources/config.yml @@ -0,0 +1,19 @@ +http-port: 8765 +# Empty values are generated on first enable, never printed to chat or logs. +admin-token: '' +agent-token: '' +project-id: default +owner-uuid: '' +world: world +world-epoch: '' +region: + min: {x: -64, y: 0, z: -64} + max: {x: 64, y: 128, z: 64} +# Enable only in an isolated local development world for the smoke test. +allow-local-automation: false +max-plan-blocks: 4096 +slice-blocks: 128 +slice-millis: 5 +camera-player-uuid: '' +camera-port: 8766 +camera-token: '' diff --git a/paper-plugin/src/main/resources/plugin.yml b/paper-plugin/src/main/resources/plugin.yml new file mode 100644 index 0000000..4c986d2 --- /dev/null +++ b/paper-plugin/src/main/resources/plugin.yml @@ -0,0 +1,14 @@ +name: MinecraftBuilderMCP +version: '0.1.0' +main: io.github.minecraftbuilder.paper.BuilderPlugin +api-version: '26.2' +description: Scoped world editing and camera bridge for AI-assisted building +commands: + ai: + description: Minecraft Builder agent and editor controls + usage: /ai setup | area | status | stop | camera | + permission: minecraftbuilder.use +permissions: + minecraftbuilder.use: + description: Access the configured builder project + default: op diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/ComponentTokensTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/ComponentTokensTest.java new file mode 100644 index 0000000..7f9ce5e --- /dev/null +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/ComponentTokensTest.java @@ -0,0 +1,32 @@ +package io.github.minecraftbuilder.paper; + +import org.junit.jupiter.api.Test; +import static org.junit.jupiter.api.Assertions.*; + +class ComponentTokensTest { + private static final String ADMIN = "a".repeat(32), AGENT = "b".repeat(32), CAMERA = "c".repeat(32); + + @Test void acceptsDistinctBoundedSafeBearerTokens() { + assertDoesNotThrow(() -> ComponentTokens.validate(ADMIN, AGENT, CAMERA)); + assertDoesNotThrow(() -> ComponentTokens.validate("a".repeat(512), "b".repeat(512), "c".repeat(506) + ".-_~09")); + } + + @Test void rejectsHeaderInjectionWhitespaceUnicodeLengthAndNullWithoutEchoingSecret() { + for (String invalid : new String[]{null, "", "short", "x".repeat(513), "dummy-private-token-that-must-not-leak\n", + "dummy-private-token-that-must-not-leak\r\nInjected: yes", "x".repeat(31) + " ", "x".repeat(31) + "ж"}) { + IllegalArgumentException error = assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, AGENT, invalid)); + assertEquals("Invalid component tokens: configure three distinct values of 32..512 safe ASCII characters", error.getMessage()); + assertFalse(error.getMessage().contains("dummy-private")); + assertFalse(error.getMessage().contains(ADMIN)); + assertFalse(error.getMessage().contains(AGENT)); + } + assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate("x\n".repeat(32), AGENT, CAMERA)); + assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, "x\n".repeat(32), CAMERA)); + } + + @Test void rejectsSharedCapabilitiesForEveryPair() { + assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, ADMIN, CAMERA)); + assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, AGENT, ADMIN)); + assertThrows(IllegalArgumentException.class, () -> ComponentTokens.validate(ADMIN, AGENT, AGENT)); + } +} diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/RpcServerTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/RpcServerTest.java new file mode 100644 index 0000000..dbce18a --- /dev/null +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/RpcServerTest.java @@ -0,0 +1,26 @@ +package io.github.minecraftbuilder.paper; + +import org.junit.jupiter.api.Test; +import java.net.*; +import java.net.http.*; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class RpcServerTest { + private static final String ADMIN = "a".repeat(48), AGENT = "b".repeat(48); + @Test void capabilitiesAreSeparatedAndIngressIsBounded() throws Exception { + try (RpcServer server = new RpcServer(0,ADMIN,AGENT,(m,p,a)->Map.of("method",m))) { + HttpClient client=HttpClient.newHttpClient(); + URI endpoint=URI.create("http://127.0.0.1:"+server.port()+"/v1/rpc"); + assertEquals(403,send(client,endpoint,"bad","{\"method\":\"project_context\"}").statusCode()); + assertEquals(403,send(client,endpoint,AGENT,"{\"method\":\"chat_poll\"}").statusCode()); + assertEquals(200,send(client,endpoint,AGENT,"{\"method\":\"project_context\"}").statusCode()); + assertEquals(200,send(client,endpoint,ADMIN,"{\"method\":\"chat_poll\"}").statusCode()); + assertTrue(send(client,endpoint,ADMIN,"x".repeat(1_048_577)).body().contains("budget_exceeded")); + assertEquals(400,send(client,endpoint,ADMIN,"not-json").statusCode()); + } + } + private HttpResponse send(HttpClient client,URI uri,String token,String body) throws Exception { + return client.send(HttpRequest.newBuilder(uri).header("Authorization","Bearer "+token).POST(HttpRequest.BodyPublishers.ofString(body)).build(),HttpResponse.BodyHandlers.ofString()); + } +} diff --git a/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/SchematicAssetsTest.java b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/SchematicAssetsTest.java new file mode 100644 index 0000000..d4cbdf0 --- /dev/null +++ b/paper-plugin/src/test/java/io/github/minecraftbuilder/paper/SchematicAssetsTest.java @@ -0,0 +1,192 @@ +package io.github.minecraftbuilder.paper; + +import io.github.minecraftbuilder.core.BlockPos; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.*; +import java.nio.file.*; +import java.util.*; +import java.util.zip.*; +import static org.junit.jupiter.api.Assertions.*; + +class SchematicAssetsTest { + @TempDir Path root; + private static final BlockPos ZERO = new BlockPos(0, 0, 0); + private static final String STONE = "minecraft:stone"; + private static final String STAIRS = "minecraft:oak_stairs[facing=north,half=bottom,shape=inner_left,waterlogged=false]"; + + @Test void roundTripKeepsEveryDenseCellAndOriginOffset() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + Map blocks = new LinkedHashMap<>(); + blocks.put(new BlockPos(-2, 70, 4), STONE); + blocks.put(new BlockPos(-1, 70, 4), "minecraft:air"); + blocks.put(new BlockPos(-2, 71, 4), "minecraft:oak_log[axis=x]"); + blocks.put(new BlockPos(-1, 71, 4), STAIRS); + BlockPos anchor = new BlockPos(-1, 69, 3); + var asset = assets.exportSnapshot("Башня", blocks, anchor, 5000); + assertEquals(new BlockPos(-1, 1, 1), asset.offset()); + assertEquals(4, asset.blockCount()); + assertEquals(5000, asset.dataVersion()); + assertEquals(64, asset.sha256().length()); + assertEquals(blocks, assets.read(asset.assetId(), anchor, 0)); + assertEquals(List.of(asset), assets.list()); + try (DataInputStream nbt = new DataInputStream(new GZIPInputStream(Files.newInputStream(root.resolve(asset.assetId() + ".schem"))))) { + assertEquals(10, nbt.readUnsignedByte()); + assertEquals("Schematic", nbt.readUTF()); + assertEquals(3, nbt.readUnsignedByte()); assertEquals("Version", nbt.readUTF()); assertEquals(2, nbt.readInt()); + } + } + + @Test void rotationMovesOffsetAndTransformsStairsLogsSlabsWithoutMirroring() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + Map blocks = Map.of(new BlockPos(1, 0, 0), STAIRS, new BlockPos(2, 0, 0), "minecraft:oak_log[axis=x]", + new BlockPos(3, 0, 0), "minecraft:oak_slab[type=top,waterlogged=false]"); + var asset = assets.exportSnapshot("Rotation", blocks, ZERO, 5000); + Map rotated = assets.read(asset.assetId(), new BlockPos(10, 64, 20), 90); + assertEquals("minecraft:oak_stairs[facing=east,half=bottom,shape=inner_left,waterlogged=false]", rotated.get(new BlockPos(10, 64, 21))); + assertEquals("minecraft:oak_log[axis=z]", rotated.get(new BlockPos(10, 64, 22))); + assertEquals("minecraft:oak_slab[type=top,waterlogged=false]", rotated.get(new BlockPos(10, 64, 23))); + assertEquals("minecraft:oak_log[axis=x]", assets.read(asset.assetId(), ZERO, 180).get(new BlockPos(-2, 0, 0))); + assertEquals("minecraft:oak_stairs[facing=west,half=bottom,shape=inner_left,waterlogged=false]", assets.read(asset.assetId(), ZERO, 270).get(new BlockPos(0, 0, -1))); + } + + @Test void independentlyWrittenFixtureUsesXThenZThenYVarintOrder() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + Files.write(root.resolve("external.schem"), fixture(2, 1, 2, List.of(STONE, "minecraft:air"), new byte[]{0, 1, 1, 0}, out -> {})); + assertEquals(Map.of(ZERO, STONE, new BlockPos(1, 0, 0), "minecraft:air", new BlockPos(0, 0, 1), "minecraft:air", new BlockPos(1, 0, 1), STONE), assets.read("external", ZERO, 0)); + } + + @Test void paletteIndicesAbove127RoundTripAsMultibyteVarints() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + List palette = new ArrayList<>(); + for (String block : List.of("oak_stairs", "spruce_stairs", "cobblestone_stairs", "stone_brick_stairs")) + for (String facing : List.of("north", "east", "south", "west")) + for (String half : List.of("bottom", "top")) + for (String shape : List.of("straight", "inner_left", "inner_right", "outer_left", "outer_right")) + palette.add("minecraft:" + block + "[facing=" + facing + ",half=" + half + ",shape=" + shape + ",waterlogged=false]"); + ByteArrayOutputStream indices = new ByteArrayOutputStream(); + Map expected = new LinkedHashMap<>(); + for (int i = 0; i < palette.size(); i++) { + if (i < 128) indices.write(i); else { indices.write((i & 127) | 128); indices.write(i >>> 7); } + expected.put(new BlockPos(i, 0, 0), palette.get(i)); + } + Files.write(root.resolve("varints.schem"), fixture(palette.size(), 1, 1, palette, indices.toByteArray(), out -> {})); + assertEquals(expected, assets.read("varints", ZERO, 0)); + var exported = assets.exportSnapshot("Large palette", expected, ZERO, 5000); + assertEquals(expected, assets.read(exported.assetId(), ZERO, 0)); + } + + @Test void rejectsSparseSnapshotUnsupportedBlocksAndUnrepresentableOrigin() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + assertThrows(IOException.class, () -> assets.exportSnapshot("Gap", Map.of(ZERO, STONE, new BlockPos(2, 0, 0), STONE), ZERO, 5000)); + assertThrows(IOException.class, () -> assets.exportSnapshot("Chest", Map.of(ZERO, "minecraft:chest"), ZERO, 5000)); + assertThrows(IOException.class, () -> assets.exportSnapshot("Offset", Map.of(new BlockPos(Integer.MIN_VALUE, 0, 0), STONE), new BlockPos(Integer.MAX_VALUE, 0, 0), 5000)); + assertEquals(0, assets.list().size()); + } + + @Test void rejectsEntitiesAndBlockEntitiesRatherThanDroppingThem() throws Exception { + for (String field : List.of("Entities", "BlockEntities")) { + Files.write(root.resolve("entity.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> { + tag(out, 9, field); out.writeByte(10); out.writeInt(1); tag(out, 8, "Id"); out.writeUTF("minecraft:pig"); out.writeByte(0); + })); + IOException error = assertThrows(IOException.class, () -> new SchematicAssets(root).read("entity", ZERO, 0)); + assertTrue(error.getMessage().contains("unsupported")); + } + } + + @Test void rejectsBiomeAndUnknownTopLevelData() throws Exception { + Files.write(root.resolve("biome.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> { tag(out, 7, "BiomeData"); out.writeInt(1); out.writeByte(0); })); + assertThrows(IOException.class, () -> new SchematicAssets(root).read("biome", ZERO, 0)); + } + + @Test void rejectsBadPaletteMissingExtraAndMalformedVarints() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + for (byte[] data : List.of(new byte[]{2}, new byte[0], new byte[]{0, 0}, new byte[]{(byte) 128}, new byte[]{(byte) 128, 0}, new byte[]{(byte) 255, (byte) 255, (byte) 255, (byte) 255, 127})) { + Files.write(root.resolve("bad.schem"), fixture(1, 1, 1, List.of(STONE), data, out -> {})); + assertThrows(IOException.class, () -> assets.read("bad", ZERO, 0)); + } + } + + @Test void rejectsDuplicateTagsUnsupportedVersionAndHugeVolume() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + Files.write(root.resolve("duplicate.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> { tag(out, 3, "Version"); out.writeInt(3); })); + assertThrows(IOException.class, () -> assets.read("duplicate", ZERO, 0)); + Files.write(root.resolve("large.schem"), fixture(4097, 1, 1, List.of(STONE), new byte[]{0}, out -> {})); + assertThrows(IOException.class, () -> assets.read("large", ZERO, 0)); + byte[] future = fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> {}); + byte[] raw = new GZIPInputStream(new ByteArrayInputStream(future)).readAllBytes(); + // Locate integer following the independent fixture's Version field and replace 2 with 3. + try (ByteArrayInputStream bytes = new ByteArrayInputStream(raw); DataInputStream in = new DataInputStream(bytes)) { + in.readByte(); in.readUTF(); in.readByte(); in.readUTF(); int at = raw.length - bytes.available(); raw[at + 3] = 3; + } + Files.write(root.resolve("future.schem"), gzip(raw)); + assertThrows(IOException.class, () -> assets.read("future", ZERO, 0)); + } + + @Test void rejectsGzipBombTruncationExcessiveDepthAndHostileArrayLength() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + Files.write(root.resolve("bomb.schem"), gzip(new byte[4_194_305])); + assertThrows(IOException.class, () -> assets.read("bomb", ZERO, 0)); + Files.write(root.resolve("short.schem"), new byte[]{31, (byte) 139}); + assertThrows(IOException.class, () -> assets.read("short", ZERO, 0)); + Files.write(root.resolve("array.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> { tag(out, 11, "Offset"); out.writeInt(Integer.MAX_VALUE); })); + assertThrows(IOException.class, () -> assets.read("array", ZERO, 0)); + Files.write(root.resolve("deep.schem"), fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> { + tag(out, 10, "Metadata"); for (int i = 0; i < 30; i++) tag(out, 10, "deep"); for (int i = 0; i < 31; i++) out.writeByte(0); + })); + assertThrows(IOException.class, () -> assets.read("deep", ZERO, 0)); + } + + @Test void rejectsPathsSymlinksAndCoordinateOverflow() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + assertThrows(IOException.class, () -> assets.read("../outside", ZERO, 0)); + Path external = Files.createTempFile("mcb-schematic-test-", ".schem"); + try { + Files.write(external, fixture(1, 1, 1, List.of(STONE), new byte[]{0}, out -> {})); + Files.createSymbolicLink(root.resolve("linked.schem"), external); + assertThrows(IOException.class, () -> assets.read("linked", ZERO, 0)); + Files.delete(root.resolve("linked.schem")); + } finally { Files.deleteIfExists(external); } + var asset = assets.exportSnapshot("Offset", Map.of(new BlockPos(1, 0, 0), STONE), ZERO, 5000); + assertThrows(IOException.class, () -> assets.read(asset.assetId(), new BlockPos(Integer.MAX_VALUE, 0, 0), 0)); + assertThrows(IOException.class, () -> assets.read(asset.assetId(), ZERO, 45)); + } + + @Test void rejectsUnspecifiedRotationalPropertiesAndUnsupportedStates() throws Exception { + assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_stairs", 90)); + assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_log", 90)); + assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_log[axis=x,axis=z]", 0)); + assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_stairs[facing=up]", 0)); + assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:oak_slab[type=top,waterlogged=true]", 0)); + assertThrows(IOException.class, () -> SchematicAssets.rotateState("minecraft:stone[rotation=4]", 90)); + } + + @Test void metadataAndPlacementRejectFutureDataVersionOnTheActualRead() throws Exception { + SchematicAssets assets = new SchematicAssets(root); + var asset = assets.exportSnapshot("Versioned", Map.of(ZERO, STONE), ZERO, 5000); + assertEquals(asset, assets.metadata(asset.assetId())); + assertThrows(IOException.class, () -> assets.read(asset.assetId(), ZERO, 0, 4999)); + assertEquals(Map.of(ZERO, STONE), assets.read(asset.assetId(), ZERO, 0, 5000)); + assertEquals(Map.of(ZERO, STONE), assets.read(asset.assetId(), ZERO, 0, 5001)); + } + + private interface Extra { void write(DataOutputStream out) throws IOException; } + /** Independent spec fixture writer, intentionally does not call codec encode. */ + private static byte[] fixture(int width, int height, int length, List palette, byte[] data, Extra extra) throws IOException { + ByteArrayOutputStream bytes = new ByteArrayOutputStream(); + try (DataOutputStream out = new DataOutputStream(bytes)) { + tag(out, 10, "Schematic"); tag(out, 3, "Version"); out.writeInt(2); tag(out, 3, "DataVersion"); out.writeInt(5000); + tag(out, 2, "Width"); out.writeShort(width); tag(out, 2, "Height"); out.writeShort(height); tag(out, 2, "Length"); out.writeShort(length); + tag(out, 3, "PaletteMax"); out.writeInt(palette.size()); tag(out, 10, "Palette"); + for (int i = 0; i < palette.size(); i++) { tag(out, 3, palette.get(i)); out.writeInt(i); } + out.writeByte(0); tag(out, 7, "BlockData"); out.writeInt(data.length); out.write(data); extra.write(out); out.writeByte(0); + } + return gzip(bytes.toByteArray()); + } + private static void tag(DataOutputStream out, int type, String name) throws IOException { out.writeByte(type); out.writeUTF(name); } + private static byte[] gzip(byte[] bytes) throws IOException { + ByteArrayOutputStream compressed = new ByteArrayOutputStream(); + try (GZIPOutputStream gzip = new GZIPOutputStream(compressed)) { gzip.write(bytes); } + return compressed.toByteArray(); + } +} diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..d09a5da --- /dev/null +++ b/pom.xml @@ -0,0 +1,11 @@ + + 4.0.0 + io.github.minecraftbuilderminecraft-builder-mcp0.1.0-SNAPSHOTpom + world-corepaper-plugin + 25UTF-826.2.build.123-stable + + org.apache.maven.pluginsmaven-compiler-plugin3.14.1 + org.apache.maven.pluginsmaven-surefire-plugin3.5.4 + org.apache.maven.pluginsmaven-jar-plugin3.4.2 + + diff --git a/scripts/bootstrap-tools.py b/scripts/bootstrap-tools.py new file mode 100755 index 0000000..6570e4d --- /dev/null +++ b/scripts/bootstrap-tools.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 +"""Install checksum-pinned local tools without changing system Java (Linux x64).""" +import hashlib +import os +from pathlib import Path +import platform +import tarfile +import urllib.request + +ROOT = Path(os.environ.get('MCB_TOOL_CACHE', str(Path.home()/'.cache/minecraft-builder-mcp'))) +TOOLS = [ + ('jdk25.tar.gz', 'https://download.java.net/java/GA/jdk25.0.2/b1e0dfa218384cb9959bdcb897162d4e/10/GPL/openjdk-25.0.2_linux-x64_bin.tar.gz', 'sha256', '555ce0821e4fe175ea50d54518cd6fbece9663c1998de529bc6ce429534457df', 'jdk-25.0.2'), + ('maven.tar.gz', 'https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.tar.gz', 'sha512', 'bcfe4fe305c962ace56ac7b5fc7a08b87d5abd8b7e89027ab251069faebee516b0ded8961445d6d91ec1985dfe30f8153268843c89aa392733d1a3ec956c9978', 'apache-maven-3.9.11'), +] +if __name__ == '__main__': + if platform.system() != 'Linux' or platform.machine() != 'x86_64': + raise SystemExit('Automatic JDK bootstrap supports Linux x64; set JAVA_HOME and use Maven 3.9.11 on your platform.') + ROOT.mkdir(parents=True, exist_ok=True) + for name, url, algorithm, checksum, directory in TOOLS: + archive = ROOT/name + if not archive.exists(): + temporary = archive.with_suffix('.download') + urllib.request.urlretrieve(url, temporary) + temporary.replace(archive) + if hashlib.new(algorithm, archive.read_bytes()).hexdigest() != checksum: + raise SystemExit(f'Checksum mismatch: {archive}; remove the corrupt archive and retry.') + if not (ROOT/directory).exists(): + with tarfile.open(archive) as tf: + tf.extractall(ROOT, filter='data') + print(ROOT/directory) diff --git a/scripts/bridge.py b/scripts/bridge.py new file mode 100644 index 0000000..3932bd6 --- /dev/null +++ b/scripts/bridge.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +"""Run the Bridge against the private local development server without displaying secrets.""" +import argparse +import json +import os +from pathlib import Path +import re +import subprocess + +ROOT = Path(__file__).resolve().parents[1] + + +def scalar(text, key): + """Read only the top-level scalar fields emitted by the Paper configuration writer.""" + match = re.search(r'^' + re.escape(key) + r':[ \t]*(.*?)[ \t]*$', text, re.M) + if not match: + return '' + raw = match.group(1).strip() + if raw.startswith("'"): + quoted = re.fullmatch(r"'((?:[^']|'')*)'[ \t]*(?:#.*)?", raw) + if quoted: + return quoted.group(1).replace("''", "'") + elif raw.startswith('"'): + quoted = re.fullmatch(r'("(?:[^"\\]|\\.)*")[ \t]*(?:#.*)?', raw) + if quoted: + try: + return json.loads(quoted.group(1)) + except json.JSONDecodeError: + pass + else: + return re.split(r'[ \t]+#', raw, maxsplit=1)[0].strip() + raise SystemExit(f'Invalid scalar for {key}; use the Paper-generated configuration format.') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('mode', choices=['doctor', 'chat', 'mcp', 'login', 'status']) + parser.add_argument('action', nargs='?', choices=['status'], help='Only for login: check authentication without starting a login flow') + parser.add_argument('--status', action='store_true', help='With login: check authentication without starting login') + parser.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml') + parser.add_argument('--console', action='store_true', help='Use console scope only with allow-local-automation in the disposable fixture') + args = parser.parse_args() + if (args.action or args.status) and args.mode != 'login': + parser.error('The status action is accepted only after login; alternatively use mode status.') + + # Login/authentication status needs no Paper config or Paper token. Always use the same + # default state directory as the chat daemon, including when called from another cwd. + env = dict(os.environ) + env.setdefault('MCB_STATE_DIR', str(ROOT / '.runtime/bridge-state')) + needs_backend = args.mode in ('chat', 'mcp') + if needs_backend and not args.config.is_file(): + raise SystemExit('Start the Paper plugin once to generate its private config.') + if args.mode in ('doctor', 'chat', 'mcp') and args.config.is_file(): + text = args.config.read_text() + actor = 'console' if args.console else scalar(text, 'owner-uuid') + if args.mode == 'mcp' and not actor: + raise SystemExit('Bind the owner in game with /ai setup first.') + port = scalar(text, 'http-port') + if not port.isdigit() or not 1 <= int(port) <= 65535: + raise SystemExit('Paper http-port must be an integer in 1..65535.') + env.update(MCB_BACKEND_URL='http://127.0.0.1:' + port, + MCB_TOKEN=scalar(text, 'admin-token'), MCB_AGENT_TOKEN=scalar(text, 'agent-token'), + MCB_PLAYER_ID=actor, MCB_PROJECT_ID=scalar(text, 'project-id')) + + target = 'dist/' + ('login' if args.mode in ('login', 'status') else args.mode) + '.js' + if not (ROOT / 'bridge' / target).is_file(): + raise SystemExit('Build the Bridge first: cd bridge && npm ci --ignore-scripts && npm run build') + command = ['node', target] + if args.mode == 'status' or args.action == 'status' or args.status: + command.append('status') + return subprocess.call(command, cwd=ROOT / 'bridge', env=env) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/build-gothic-hall.py b/scripts/build-gothic-hall.py new file mode 100644 index 0000000..5c22d1b --- /dev/null +++ b/scripts/build-gothic-hall.py @@ -0,0 +1,136 @@ +#!/usr/bin/env python3 +"""Plan/apply/verify the Gothic reference build through checked Paper editor operations.""" +import argparse +from collections import Counter +import hashlib +import json +import os +from pathlib import Path +import re +import time +import urllib.error +import urllib.request +import uuid + +from builds.gothic_hall.scene import build_scene, manifest + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / '.runtime/gothic-hall' +CONFIG = ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml' + +def save(path, data): + temp = path.with_suffix('.tmp') + with temp.open('w') as stream: + json.dump(data, stream, indent=2) + stream.write('\n'); stream.flush(); os.fsync(stream.fileno()) + temp.replace(path) + +def digest(data): + return hashlib.sha256(json.dumps(data, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + +def point_key(value): + return value['x'], value['y'], value['z'] + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['plan', 'apply', 'verify']) + args = parser.parse_args() + OUTPUT.mkdir(exist_ok=True) + config = CONFIG.read_text() + def scalar(key): + return re.search(r'^' + re.escape(key) + r':[ \t]*(.*?)$', config, re.M).group(1).strip().strip("'\"") + scope = {'player_id': scalar('owner-uuid'), 'project_id': scalar('project-id')} + assert scope['player_id'], 'An online project owner is required' + token = scalar('agent-token') + opener = urllib.request.build_opener(urllib.request.ProxyHandler({})) + def rpc(method, params): + for attempt in range(100): + data = {'method': method, 'params': {**params, **scope}, 'requestId': str(uuid.uuid4())} + req = urllib.request.Request('http://127.0.0.1:' + scalar('http-port') + '/v1/rpc', + data=json.dumps(data).encode(), headers={'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json'}) + try: + with opener.open(req, timeout=30) as response: body = json.load(response) + except urllib.error.HTTPError as error: + body = json.load(error) + if body.get('ok'): return body['result'] + code = body.get('error', {}).get('code', 'unknown') + if code == 'busy': time.sleep(.1); continue + raise RuntimeError(f'{method}: {code}; no blind overwrite or replay performed') + raise RuntimeError('Paper remained busy') + + context = rpc('project_context', {}) + if args.action == 'plan': + voxels = build_scene(); model = manifest(voxels) + allowed = set(context['supported_materials']) + assert all(block.split('[')[0] in allowed for block in model['palette']), 'Unsupported material in blueprint' + for batch in model['batches']: + assert batch['blocks'] <= 4096 + expanded = {} + for operation in batch['recipe']['operations']: + a, b = operation['min'], operation['max'] + for y in range(a['y'], b['y'] + 1): + for z in range(a['z'], b['z'] + 1): + for x in range(a['x'], b['x'] + 1): + assert (x, y, z) not in expanded, 'Compression overlapped a voxel' + local = tuple(n - o for n, o in zip((x, y, z), model['origin'])) + assert voxels.blocks[local] == operation['block'], 'Compression changed the blueprint' + expanded[(x, y, z)] = operation['block'] + assert len(expanded) == batch['blocks'] + for axis in ('x', 'y', 'z'): + assert context['region']['min'][axis] <= batch['min'][axis] <= batch['max'][axis] <= context['region']['max'][axis] + model['world_id'] = context['world_id']; model['world_epoch'] = context['world_epoch'] + path = OUTPUT / 'manifest.json' + if (OUTPUT / 'ledger.json').exists() and path.exists(): + assert digest(json.loads(path.read_text())) == digest(model), 'Existing live build has a different blueprint; preserve it' + save(path, model) + print(json.dumps({'blocks': model['blocks'], 'batches': len(model['batches']), 'stages': model['stages'], + 'max_recipe_operations': max(len(b['recipe']['operations']) for b in model['batches'])}), flush=True) + return + + model = json.loads((OUTPUT / 'manifest.json').read_text()) + assert model['world_id'] == context['world_id'] and model['world_epoch'] == context['world_epoch'], 'World identity changed' + ledger_path = OUTPUT / 'ledger.json' + ledger = json.loads(ledger_path.read_text()) if ledger_path.exists() else {'manifest_sha256': digest(model), 'batches': {}} + assert ledger['manifest_sha256'] == digest(model), 'Blueprint changed after writing began' + for index, batch in enumerate(model['batches'], 1): + record = ledger['batches'].get(batch['key']) + if args.action == 'apply': + if record is None: + before = rpc('region_inspect', {'min': batch['min'], 'max': batch['max'], 'detail': 'summary'}) + assert set(before['palette']) == {'minecraft:air'}, f"Occupied site in batch {batch['key']}; preserve and review" + plan = rpc('build_prepare', {'recipe': batch['recipe']}) + # The immutable persisted plan must still agree with the reviewed empty site. + envelope = json.loads((CONFIG.parent / 'journal/plans' / (plan['plan_id'] + '.json')).read_text()) + persisted = json.loads(envelope['payload']) + assert all(change['expected'] == 'minecraft:air' for change in persisted['changes']), 'Concurrent edit before prepare; stop' + record = {'plan': plan, 'idempotency_key': 'gothic-hall-' + plan['plan_id']} + ledger['batches'][batch['key']] = record; save(ledger_path, ledger) + if not record.get('operation_id'): + operation = rpc('build_apply', {**record['plan'], 'idempotency_key': record['idempotency_key']}) + record['operation_id'] = operation['operation_id']; save(ledger_path, ledger) + for _ in range(600): + status = rpc('operation_status', {'operation_id': record['operation_id']}) + if status['status'] not in ('queued', 'applying'): break + time.sleep(.1) + record['status'] = status; save(ledger_path, ledger) + assert status['status'] == 'applied', f"Batch {batch['key']} stopped: {status['status']}; inspect the operation" + print(json.dumps({'batch': index, 'of': len(model['batches']), 'key': batch['key'], 'written': status['written'], + 'stages': batch['stages'], 'operation_id': record['operation_id']}), flush=True) + else: + assert record and record.get('status', {}).get('status') == 'applied', 'Build is not fully applied' + envelope = json.loads((CONFIG.parent / 'journal/plans' / (record['plan']['plan_id'] + '.json')).read_text()) + planned = json.loads(envelope['payload']) + current = rpc('region_inspect', {'min': batch['min'], 'max': batch['max'], 'detail': 'blocks'}) + states = {point_key(block['pos']): block['state'] for block in current['blocks']} + mismatches = [change['pos'] for change in planned['changes'] if states[point_key(change['pos'])] != change['desired']] + assert not mismatches, f"Live geometry differs in batch {batch['key']} at {mismatches[:5]}; preserve edits" + if args.action == 'verify': + report = {'status': 'verified', 'blocks': model['blocks'], 'batches': len(model['batches']), 'verified_at': time.time(), + 'manifest_sha256': digest(model), 'scope': scope} + save(OUTPUT / 'verification.json', report) + print(json.dumps(report), flush=True) + else: + print('GOTHIC HALL BUILD APPLIED', flush=True) + +if __name__ == '__main__': + main() diff --git a/scripts/build.sh b/scripts/build.sh new file mode 100755 index 0000000..2a23594 --- /dev/null +++ b/scripts/build.sh @@ -0,0 +1,10 @@ +#!/usr/bin/env bash +set -euo pipefail +project_root="$(cd -- "$(dirname -- "$0")/.." && pwd)" +cd "$project_root" +tool_cache="${MCB_TOOL_CACHE:-${XDG_CACHE_HOME:-$HOME/.cache}/minecraft-builder-mcp}" +./mvnw package +npm --prefix bridge ci --ignore-scripts +npm --prefix bridge test +export JAVA_HOME="${MCB_JAVA_HOME:-$tool_cache/jdk-25.0.2}" +camera-mod/gradlew --project-dir camera-mod build diff --git a/scripts/builds/gothic_hall/main_hall.py b/scripts/builds/gothic_hall/main_hall.py new file mode 100644 index 0000000..1959d5b --- /dev/null +++ b/scripts/builds/gothic_hall/main_hall.py @@ -0,0 +1,291 @@ +"""Main Gothic hall geometry in local coordinates; pure voxel composition, no IO.""" + + +def build(v): + """Compose the north-facing hall, hollow interior, roof, and attached masonry.""" + stone = "minecraft:stone_bricks" + ashlar = "minecraft:polished_andesite" + plinth = "minecraft:andesite" + pale = "minecraft:polished_diorite" + dark = "minecraft:deepslate_tiles" + glass = "minecraft:tinted_glass" + wood = "minecraft:dark_oak_planks" + oak = "minecraft:spruce_planks" + + def slab(material="stone_brick", half="bottom"): + return f"minecraft:{material}_slab[type={half},waterlogged=false]" + + def stair(material, facing, half="bottom"): + return (f"minecraft:{material}_stairs[facing={facing},half={half}," + "shape=straight,waterlogged=false]") + + def log(axis="y"): + return f"minecraft:dark_oak_log[axis={axis}]" + + def roof_height(x): + return 43 - (abs(x - 19) * 16 + 13) // 14 + + def pinnacle(x, z, base, height=8): + """Square masonry shaft with a cap, taper, and slender stone finial.""" + v.box(x - 1, base, z - 1, x + 1, base, z + 1, ashlar) + v.box(x, base + 1, z, x, base + height - 3, z, stone) + v.box(x - 1, base + height - 4, z - 1, + x + 1, base + height - 4, z + 1, slab()) + v.set(x, base + height - 2, z, ashlar) + v.set(x, base + height - 1, z, slab()) + + def side_window(face, inner, center, bottom, tops, trim_face): + """Carved pointed aperture with glass one block behind the wall face.""" + half = max(tops) + lo, hi = sorted((face, inner)) + for dz in range(-half, half + 1): + top = tops[abs(dz)] + v.clear(lo, bottom, center + dz, hi, top, center + dz) + v.box(inner, bottom, center + dz, inner, top, center + dz, glass) + v.set(trim_face, top + 1, center + dz, ashlar) + for dz in (-half - 1, half + 1): + v.box(trim_face, bottom - 1, center + dz, + trim_face, tops[half] + 1, center + dz, ashlar) + v.box(trim_face, bottom - 1, center - half - 1, + trim_face, bottom - 1, center + half + 1, pale) + # Alternating vertical/horizontal pieces describe the arch, rather than + # colouring an arch outline on an otherwise flat facade. + for dz in range(-half, half + 1): + top = tops[abs(dz)] + if dz: + facing = "south" if dz < 0 else "north" + v.set(trim_face, top + 2, center + dz, stair("stone_brick", facing)) + v.set(trim_face, tops[0] + 2, center, slab()) + + def front_aperture(z_face, z_glass, center, bottom, tops): + for dx in range(-max(tops), max(tops) + 1): + top = tops[abs(dx)] + v.clear(center + dx, bottom, z_face, center + dx, top, z_glass) + v.box(center + dx, bottom, z_glass, center + dx, top, z_glass, glass) + + # Dense foundations; two genuinely hollow occupied storeys above them. + v.box(7, 0, 13, 31, 0, 56, plinth) + v.box(7, 1, 13, 31, 1, 56, ashlar) + v.box(7, 2, 13, 31, 27, 56, stone) + v.clear(9, 2, 15, 29, 5, 54) + v.clear(9, 7, 15, 29, 43, 54) + v.box(9, 6, 15, 29, 6, 54, wood) + v.box(17, 6, 15, 21, 6, 53, ashlar) + for x in (9, 29): + v.box(x, 6, 15, x, 6, 54, stone) + for z in (15, 54): + v.box(9, 6, z, 29, 6, z, stone) + + # Continuous ledges make the raised main floor legible from the courtyard. + for x in (6, 32): + v.box(x, 0, 13, x, 1, 56, ashlar) + v.box(x, 5, 13, x, 5, 56, stone) + v.box(x, 6, 13, x, 6, 56, slab("smooth_stone")) + v.box(x, 25, 12, x, 25, 57, ashlar) + v.box(x, 26, 12, x, 26, 57, pale) + v.box(x, 27, 12, x, 27, 57, slab()) + for z in (12, 57): + v.box(6, 5, z, 32, 5, z, stone) + v.box(6, 6, z, 32, 6, z, slab("smooth_stone")) + v.box(6, 25, z, 32, 25, z, ashlar) + v.box(6, 26, z, 32, 26, z, pale) + # Timber is visible in the short shadow immediately below the roof edge. + for x in (7, 31): + v.box(x, 26, 15, x, 26, 54, log("z")) + for z in range(15, 55, 2): + v.set(x, 25, z, stair("spruce", "east" if x == 7 else "west", "top")) + + bays = (18, 26, 34, 42, 50) + piers = (14, 22, 30, 38, 46, 54) + for center in bays: + for face, inner, trim in ((7, 8, 6), (31, 30, 32)): + side_window(face, inner, center, 10, {0: 22, 1: 21, 2: 19}, trim) + # The recessed central mullion and transom divide four dark lancets. + v.box(inner, 10, center, inner, 20, center, ashlar) + v.box(inner, 15, center - 2, inner, 15, center + 2, ashlar) + v.set(inner, 21, center, wood) + # A modest three-wide basement window below each high bay. + side_window(face, inner, center, 2, {0: 4, 1: 3}, trim) + v.set(inner, 2, center, wood) + # Deep, bevelled main-window sill; its ends connect to the piers. + outside = trim - 1 if trim < 19 else trim + 1 + v.box(outside, 8, center - 3, outside, 8, center + 3, slab()) + v.box(trim, 9, center - 3, trim, 9, center + 3, stone) + + # Each buttress has an outward foot and two real setbacks. + for z in piers: + for left in (True, False): + if left: + foot, middle, shaft, outside = (3, 7), (4, 7), (5, 7), 5 + else: + foot, middle, shaft, outside = (31, 35), (31, 34), (31, 33), 33 + v.box(foot[0], 0, z - 1, foot[1], 1, z + 1, plinth) + v.box(middle[0], 2, z - 1, middle[1], 5, z + 1, stone) + v.box(middle[0], 6, z - 1, middle[1], 6, z + 1, slab("smooth_stone")) + v.box(shaft[0], 7, z - 1, shaft[1], 11, z + 1, stone) + v.box(shaft[0], 12, z - 1, shaft[1], 12, z + 1, ashlar) + v.box(min(outside, 7 if left else 31), 13, z, + max(outside, 7 if left else 31), 24, z, stone) + v.set(outside, 19, z, ashlar) + v.box(shaft[0], 25, z - 1, shaft[1], 25, z + 1, ashlar) + v.set(outside, 26, z, slab("smooth_stone")) + pinnacle(outside + (1 if left else -1), z, 27, 7) + + # Recessed rear lancets, kept separate from the side gallery connection. + for center in (12, 19, 26): + front_aperture(55, 56, center, 10, {0: 22, 1: 20}) + for dx in (-2, 2): + v.box(center + dx, 9, 57, center + dx, 21, 57, ashlar) + v.set(center, 24, 57, ashlar) + v.box(center - 2, 9, 57, center + 2, 9, 57, pale) + + # Steep, two-cell-thick stepped roof. Both sides slope to the central ridge. + for x in range(5, 34): + high = roof_height(x) + if x == 19: + v.box(x, 42, 12, x, 43, 57, dark) + else: + v.box(x, high - 1, 12, x, high, 57, dark) + v.box(x, high, 12, x, high, 57, + stair("deepslate_tile", "east" if x < 19 else "west")) + # A restrained ridge crest uses slabs and piers instead of unsupported fences. + v.box(19, 43, 14, 19, 43, 55, slab("stone_brick")) + for z in range(15, 56, 4): + v.set(19, 43, z, ashlar) + + # Front and rear stepped gables: substantial masonry underneath the coping. + for z in (12, 57): + for x in range(6, 33): + high = roof_height(x) + v.box(x, 27, z, x, high, z, stone) + v.set(x, high, z, ashlar) + if x != 19: + v.set(x, high + 1, z, stair("stone_brick", "east" if x < 19 else "west")) + v.box(19, 44, z, 19, 46, z, ashlar) + v.box(18, 47, z, 20, 47, z, stone) + v.set(19, 48, z, slab()) + + # Tall north facade, projecting three-layer porch, and its open pointed door. + v.box(13, 6, 9, 25, 6, 14, ashlar) + v.box(14, 6, 8, 24, 6, 8, ashlar) + portal_top = {0: 19, 1: 18, 2: 17, 3: 15, 4: 13} + for dx, top in portal_top.items(): + for sign in (-1, 1) if dx else (1,): + x = 19 + sign * dx + v.clear(x, 7, 9, x, top, 15) + for z, material, expand in ((9, ashlar, 1), (10, stone, 0), (11, pale, 0)): + for dx in (-5, 5): + v.box(19 + dx, 7, z, 19 + dx, 14, z, material) + for dx, top in portal_top.items(): + for sign in (-1, 1) if dx else (1,): + x = 19 + sign * dx + v.box(x, top + 1, z, x, top + 1 + expand, z, material) + for x in (13, 25): + v.box(x, 7, 10, x, 14, 12, stone) + v.box(x, 15, 10, x, 15, 12, slab()) + # Open wooden leaves are flush with the inner jambs, not across the entrance. + v.box(15, 7, 14, 15, 12, 15, log()) + v.box(23, 7, 14, 23, 12, 15, log()) + v.clear(16, 7, 9, 22, 12, 15) + + # Two slim flanking lancets and a large upper traceried front window. + for center in (10, 28): + front_aperture(12, 14, center, 10, {0: 23, 1: 21}) + for x in (center - 2, center + 2): + v.box(x, 9, 11, x, 22, 11, ashlar) + v.set(center, 25, 11, slab()) + v.box(center - 2, 9, 11, center + 2, 9, 11, pale) + front_aperture(12, 14, 19, 29, {0: 39, 1: 38, 2: 36, 3: 33}) + for dx, top in {0: 39, 1: 38, 2: 36, 3: 33}.items(): + for sign in (-1, 1) if dx else (1,): + v.set(19 + dx * sign, top + 1, 11, ashlar) + for x in (15, 23): + v.box(x, 28, 11, x, 34, 11, ashlar) + for x in (18, 20): + v.box(x, 29, 14, x, 36, 14, ashlar) + v.box(16, 33, 14, 22, 33, 14, ashlar) + v.box(15, 28, 11, 23, 28, 11, pale) + # Small heraldic mosaic echoes the reference's central hanging cross. + v.box(17, 22, 11, 21, 27, 11, dark) + v.box(19, 23, 10, 19, 26, 10, pale) + v.box(18, 25, 10, 20, 25, 10, pale) + v.set(19, 21, 11, dark) + for x in (7, 31): + v.box(x - 1, 2, 11, x + 1, 9, 13, stone) + v.box(x, 10, 11, x, 28, 12, ashlar) + for y in (6, 13, 25): + v.box(x - 1, y, 10, x + 1, y, 13, slab("smooth_stone")) + pinnacle(x, 12, 29, 9) + + # Five small pointed dormers on each long roof slope. + for z in bays: + for left in (True, False): + lo, hi, face, inner = (10, 14, 10, 11) if left else (24, 28, 28, 27) + v.clear(lo, 33, z - 1, hi, 36, z + 1) + for dz in (-2, 2): + v.box(lo, 32, z + dz, hi, 37, z + dz, dark) + v.box(face, 32, z - 1, face, 37, z + 1, ashlar) + v.clear(min(face, inner), 34, z, max(face, inner), 36, z) + v.box(inner, 34, z, inner, 36, z, glass) + for dz in range(-2, 3): + top = 39 - abs(dz) + v.box(lo, top, z + dz, hi, top, z + dz, dark) + v.set(face, top, z + dz, ashlar) + v.set(face, 40, z, slab()) + + # Galleries retain a broad, open central nave and three-block walkways. + for x1, x2 in ((9, 12), (26, 29)): + v.box(x1, 16, 16, x2, 16, 52, wood) + rail = x2 if x1 < 19 else x1 + v.box(rail, 17, 16, rail, 17, 52, slab("stone_brick")) + for z in range(16, 53, 4): + v.set(rail, 17, z, ashlar) + v.set(rail, 18, z, slab()) + for z in piers[1:]: + if z > 52: + continue + v.box(rail, 7, z, rail, 15, z, stone) + v.box(rail - 1, 7, z - 1, rail + 1, 7, z + 1, ashlar) + v.box(rail - 1, 15, z, rail + 1, 15, z, ashlar) + # A rear bridge connects both galleries to the single staircase. + v.box(9, 16, 51, 29, 16, 53, wood) + v.box(12, 17, 50, 25, 17, 50, slab()) + for x in (12, 19, 25): + v.set(x, 17, 50, ashlar) + v.set(x, 18, 50, slab()) + for x in (12, 26): + v.box(x, 7, 52, x, 15, 52, stone) + # Transverse oak roof frames are visible from the nave between the dormers. + for z in (22, 30, 38, 46, 54): + v.box(9, 28, z, 29, 28, z, log("x")) + for x in range(9, 30): + y = 40 - abs(x - 19) + v.set(x, y, z, wood) + for x in (9, 29): + v.box(x, 19, z, x, 28, z, log()) + + # Rear right gallery stair: ten rises, with its complete headroom shaft. + v.clear(26, 7, 39, 28, 19, 49) + for step in range(10): + y, z = 7 + step, 40 + step + if y > 7: + v.box(26, 7, z, 28, y - 1, z, wood) + v.box(26, y, z, 28, y, z, stair("spruce", "south")) + # Separate basement access under the west gallery; the main floor is pierced. + v.clear(10, 2, 43, 11, 9, 48) + for step in range(5): + y, z = 2 + step, 44 + step + if y > 2: + v.box(10, 2, z, 11, y - 1, z, stone) + v.box(10, y, z, 11, y, z, stair("stone_brick", "south")) + + # Modest furnishings preserve a five-block processional route to the dais. + v.box(14, 7, 50, 24, 7, 53, wood) + v.box(16, 7, 49, 22, 7, 49, stair("spruce", "south")) + for z in (24, 30, 36, 42): + for x1, x2 in ((14, 16), (22, 24)): + v.box(x1, 7, z, x2, 7, z, stair("spruce", "north")) + v.set(x1, 8, z, slab("spruce")) + v.set(x2, 8, z, slab("spruce")) + # Agreed lower-level passage to the east gallery/wing, cut after decoration. + v.clear(30, 7, 36, 35, 11, 39) diff --git a/scripts/builds/gothic_hall/scene.py b/scripts/builds/gothic_hall/scene.py new file mode 100644 index 0000000..4324cc3 --- /dev/null +++ b/scripts/builds/gothic_hall/scene.py @@ -0,0 +1,157 @@ +"""Deterministic voxel blueprint for the generated Gothic hall reference.""" +from collections import Counter, defaultdict +import importlib +import re + +ORIGIN = (-50, -60, -40) + +class Voxels: + def __init__(self): + self.blocks = {} + self.stage = 'site' + self.owners = {} + + def set(self, x, y, z, block): + assert all(isinstance(v, int) for v in (x, y, z)), (x, y, z) + assert -2 <= x <= 68 and -1 <= y <= 64 and -2 <= z <= 68, (x, y, z) + if not block.startswith('minecraft:'): + block = 'minecraft:' + block + assert re.fullmatch(r'minecraft:[a-z0-9_]+(?:\[[a-z0-9_=,]+\])?', block), block + if block == 'minecraft:air': + self.blocks.pop((x, y, z), None) + self.owners.pop((x, y, z), None) + else: + self.blocks[(x, y, z)] = block + self.owners[(x, y, z)] = self.stage + + def box(self, x1, y1, z1, x2, y2, z2, block): + x1, x2 = sorted((x1, x2)) + y1, y2 = sorted((y1, y2)) + z1, z2 = sorted((z1, z2)) + for y in range(y1, y2 + 1): + for z in range(z1, z2 + 1): + for x in range(x1, x2 + 1): + self.set(x, y, z, block) + + def clear(self, x1, y1, z1, x2, y2, z2): + self.box(x1, y1, z1, x2, y2, z2, 'air') + +def site(v): + v.box(1, 0, 1, 63, 0, 62, 'smooth_stone') + for z in (1, 62): + v.box(1, 0, z, 63, 0, z, 'polished_andesite') + for x in (1, 63): + v.box(x, 0, 1, x, 0, 62, 'polished_andesite') + # A few continuous paving bands give the terrace scale without noisy texture. + for x in (5, 33, 60): + v.box(x, 0, 2, x, 0, 61, 'stone_bricks') + for z in (4, 59): + v.box(2, 0, z, 62, 0, z, 'stone_bricks') + for step in range(1, 7): + z = step + 1 + v.box(13, 1, z, 25, step, z, 'stone_bricks') + v.box(14, step, z, 24, step, z, + 'stone_brick_stairs[facing=south,half=bottom,shape=straight,waterlogged=false]') + for x in (12, 26): + v.box(x, 1, z, x, step + 1, z, 'polished_andesite') + v.set(x, step + 2, z, 'stone_brick_slab[type=bottom,waterlogged=false]') + v.box(13, 1, 8, 25, 6, 9, 'stone_bricks') + for x in (8, 30, 35, 61): + for z in (6, 59): + v.box(x, 1, z, x + 1, 1, z + 1, 'polished_andesite') + v.box(x, 2, z, x + 1, 3, z + 1, 'stone_bricks') + v.box(x, 4, z, x + 1, 4, z + 1, + 'stone_brick_slab[type=bottom,waterlogged=false]') + +def connection(v): + # Two-level enclosed passage between the great hall and companion wing. + v.stage = 'connecting gallery' + v.box(31, 0, 34, 40, 6, 42, 'stone_bricks') + v.box(31, 7, 34, 40, 13, 34, 'stone_bricks') + v.box(31, 7, 42, 40, 13, 42, 'stone_bricks') + v.box(31, 13, 33, 40, 13, 43, 'polished_andesite') + v.box(31, 14, 34, 40, 14, 42, 'deepslate_tiles') + v.box(31, 15, 36, 40, 15, 40, 'deepslate_tiles') + v.box(31, 16, 38, 40, 16, 38, 'deepslate_tiles') + for z, facing, y in [(34, 'south', 14), (35, 'south', 14), + (36, 'south', 15), (37, 'south', 15), + (39, 'north', 15), (40, 'north', 15), + (41, 'north', 14), (42, 'north', 14)]: + v.box(31, y, z, 40, y, z, + f'deepslate_tile_stairs[facing={facing},half=bottom,shape=straight,waterlogged=false]') + v.clear(30, 7, 36, 32, 11, 39) + v.clear(33, 8, 36, 34, 11, 39) + v.clear(35, 9, 36, 40, 12, 39) + v.box(31, 6, 36, 32, 6, 39, 'spruce_planks') + for x, y in [(33, 6), (34, 7)]: + v.box(x, y, 36, x, y, 39, + 'spruce_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]') + v.box(35, 8, 36, 40, 8, 39, 'spruce_planks') + +def build_scene(): + v = Voxels() + site(v) + for name, stage in [('main_hall', 'great hall'), ('side_wing', 'side wing'), ('tower', 'bell tower')]: + v.stage = stage + importlib.import_module('builds.gothic_hall.' + name).build(v) + connection(v) + return v + +def boxes(blocks): + """Merge runs on X, then Z and Y, preserving the exact sparse block mask.""" + rows = defaultdict(list) + for (x, y, z), block in blocks.items(): + rows[(y, z, block)].append(x) + runs = [] + for (y, z, block), xs in sorted(rows.items()): + xs.sort(); start = end = xs[0] + for x in xs[1:]: + if x == end + 1: + end = x + else: + runs.append((start, y, z, end, y, z, block)); start = end = x + runs.append((start, y, z, end, y, z, block)) + merged = [] + groups = defaultdict(list) + for x1, y1, z1, x2, y2, z2, block in runs: + groups[(x1, x2, y1, block)].append(z1) + for (x1, x2, y, block), zs in sorted(groups.items()): + zs.sort(); start = end = zs[0] + for z in zs[1:]: + if z == end + 1: end = z + else: + merged.append((x1, y, start, x2, y, end, block)); start = end = z + merged.append((x1, y, start, x2, y, end, block)) + result = []; groups = defaultdict(list) + for x1, y1, z1, x2, y2, z2, block in merged: + groups[(x1, x2, z1, z2, block)].append(y1) + for (x1, x2, z1, z2, block), ys in sorted(groups.items()): + ys.sort(); start = end = ys[0] + for y in ys[1:]: + if y == end + 1: end = y + else: + result.append((x1, start, z1, x2, end, z2, block)); start = end = y + result.append((x1, start, z1, x2, end, z2, block)) + return result + +def manifest(v): + tiles = defaultdict(dict) + for p, block in v.blocks.items(): + tiles[(p[1] // 8, p[2] // 16, p[0] // 16)][p] = block + batches = [] + for key, data in sorted(tiles.items()): + recipe = [] + for x1, y1, z1, x2, y2, z2, block in boxes(data): + lo = dict(zip(('x', 'y', 'z'), (x1 + ORIGIN[0], y1 + ORIGIN[1], z1 + ORIGIN[2]))) + hi = dict(zip(('x', 'y', 'z'), (x2 + ORIGIN[0], y2 + ORIGIN[1], z2 + ORIGIN[2]))) + recipe.append({'type': 'box', 'min': lo, 'max': hi, 'block': block}) + positions = list(data) + minimum = [min(p[i] for p in positions) + ORIGIN[i] for i in range(3)] + maximum = [max(p[i] for p in positions) + ORIGIN[i] for i in range(3)] + batches.append({'key': '-'.join(map(str, key)), 'blocks': len(data), + 'min': dict(zip(('x', 'y', 'z'), minimum)), 'max': dict(zip(('x', 'y', 'z'), maximum)), + 'recipe': {'version': 1, 'operations': recipe}, + 'stages': dict(Counter(v.owners[p] for p in data))}) + return {'name': 'Gothic hall reference reconstruction', 'origin': ORIGIN, + 'blocks': len(v.blocks), 'stages': dict(Counter(v.owners.values())), + 'palette': dict(Counter(v.blocks.values())), 'batches': batches} diff --git a/scripts/builds/gothic_hall/side_wing.py b/scripts/builds/gothic_hall/side_wing.py new file mode 100644 index 0000000..e69ead7 --- /dev/null +++ b/scripts/builds/gothic_hall/side_wing.py @@ -0,0 +1,176 @@ +"""The smaller, two-storey gabled companion to the main Gothic hall. + +Coordinates are inclusive and local to the complete composition. ``build`` +only writes through the supplied voxel API; it does not contact Minecraft. +""" + +STONE = "minecraft:stone_bricks" +TRIM = "minecraft:polished_andesite" +LIGHT = "minecraft:polished_diorite" +GLASS = "minecraft:tinted_glass" +WOOD = "minecraft:spruce_planks" +BEAM = "minecraft:stripped_spruce_log[axis=y]" +ROOF = "minecraft:deepslate_tiles" +AIR = "minecraft:air" + + +def _stair(material, facing, half="bottom"): + return ( + f"minecraft:{material}[facing={facing},half={half}," + "shape=straight,waterlogged=false]" + ) + + +def _wall_block(x, y, z): + """Sparse, deterministic masonry variation, without a checkerboard.""" + n = (x * 31 + y * 17 + z * 47 + x * z * 3) % 53 + if n < 3: + return "minecraft:cracked_stone_bricks" + if n < 8: + return "minecraft:andesite" + return STONE + + +def _lancet(v, left, right, bottom, top, wall, outside, north=False): + """A stone pointed surround, open reveal, and recessed dark glass.""" + center = (left + right) / 2 + + def point(u, y, depth, block): + if north: + v.set(u, y, depth, block) + else: + v.set(depth, y, u, block) + + for u in range(left - 1, right + 2): + cap = top + 1 - int(abs(u - center)) + for y in range(bottom - 1, cap + 1): + point(u, y, outside, LIGHT if y == cap else TRIM) + for u in range(left, right + 1): + cap = top - int(abs(u - center)) + for y in range(bottom, cap + 1): + point(u, y, outside, AIR) + point(u, y, wall, GLASS) + # The sill projects one block; the glass stays in the wall behind it. + for u in range(left - 1, right + 2): + point(u, bottom - 1, outside, LIGHT) + + +def _buttress(v, west, z): + """Three diminishing stages, with projecting pale weathering caps.""" + outer, inner = (36, 38) if west else (57, 59) + v.box(outer, 0, z - 1, inner, 1, z + 1, TRIM) + a, b = (37, 38) if west else (57, 58) + v.box(a, 2, z - 1, b, 6, z + 1, STONE) + v.box(outer, 7, z - 1, inner, 7, z + 1, LIGHT) + v.box(a, 8, z, b, 14, z, STONE) + v.box(a, 15, z - 1, b, 15, z + 1, TRIM) + v.box(a, 16, z, b, 18, z, STONE) + v.box(a, 19, z, b, 19, z, LIGHT) + + +def _entrance(v): + # Two nested pointed archivolts give the entrance visible depth. + for z, left, right, peak, block in ( + (14, 44, 51, 8, TRIM), + (15, 45, 50, 7, LIGHT), + ): + for x in range(left, right + 1): + cap = peak - int(abs(x - 47.5)) + v.box(x, 1, z, x, cap, z, block) + for x in range(46, 50): + cap = 6 - int(abs(x - 47.5)) + v.clear(x, 1, 14, x, cap, 17) + # Timber inner jambs suggest the tall wooden doorway, leaving it usable. + v.box(45, 1, 16, 45, 4, 16, BEAM) + v.box(50, 1, 16, 50, 4, 16, BEAM) + v.box(44, 0, 13, 51, 0, 16, TRIM) + v.box(46, 0, 14, 49, 0, 17, "minecraft:smooth_stone") + + +def build(v): + """Build within x=36..59, y=0..28, z=13..43.""" + # Hollow masonry shell, an accessible ground floor and an upper floor. + v.box(38, 0, 16, 57, 0, 41, TRIM) + v.clear(39, 1, 17, 56, 27, 40) + for y in range(1, 18): + for x in range(38, 58): + for z in (16, 41): + v.set(x, y, z, _wall_block(x, y, z)) + for z in range(17, 41): + for x in (38, 57): + v.set(x, y, z, _wall_block(x, y, z)) + v.box(39, 0, 17, 56, 0, 40, WOOD) + v.box(39, 8, 17, 56, 8, 40, WOOD) + + # Floor bands and the projecting eaves visually join the two storeys. + for y, block in ((0, TRIM), (7, TRIM), (8, LIGHT), (16, TRIM)): + v.box(37, y, 15, 58, y, 15, block) + v.box(37, y, 42, 58, y, 42, block) + v.box(58, y, 16, 58, y, 41, block) + for z in range(16, 42): + # The main composition will connect the gallery through here. + if not (7 <= y <= 11 and 36 <= z <= 39): + v.set(37, y, z, block) + + for z in (17, 25, 33, 41): + _buttress(v, True, z) + _buttress(v, False, z) + for x in (38, 57): + v.box(x - 1, 0, 14, x + 1, 1, 16, TRIM) + v.box(x, 2, 14, x, 16, 16, STONE) + v.box(x - 1, 17, 14, x + 1, 17, 16, LIGHT) + v.box(x, 18, 15, x, 20, 15, STONE) + v.set(x, 21, 15, LIGHT) + + # A paired rhythm of small lower and tall upper side windows. + for z in (21, 29, 37): + for wall, outside in ((38, 37), (57, 58)): + if wall == 38 and z == 37: + continue # Keep the gallery doorway and its approach clear. + _lancet(v, z - 1, z + 1, 2, 6, wall, outside) + _lancet(v, z - 1, z + 1, 10, 15, wall, outside) + for left, right in ((41, 42), (53, 54)): + _lancet(v, left, right, 2, 6, 16, 15, north=True) + for left, right in ((42, 45), (50, 53)): + _lancet(v, left, right, 10, 15, 16, 15, north=True) + _entrance(v) + + # Timber supports remain against the edges, leaving the rooms traversable. + for x in (40, 55): + for z in (18, 39): + v.box(x, 1, z, x, 7, z, BEAM) + for z in (23, 31, 39): + v.box(39, 7, z, 56, 7, z, "minecraft:stripped_spruce_log[axis=x]") + v.clear(53, 8, 28, 55, 11, 35) + for step in range(8): + y, z = step + 1, 28 + step + if y > 1: + v.box(53, 1, z, 55, y - 1, z, WOOD) + v.box(53, y, z, 55, y, z, _stair("spruce_stairs", "south")) + v.clear(53, y + 1, z, 55, y + 2, z) + v.box(52, 9, 28, 52, 9, 35, "minecraft:spruce_slab[type=bottom,waterlogged=false]") + + # A steep roof, dark in the middle and bounded by pale stepped gables. + for x in range(37, 59): + rise = min(x - 37, 58 - x) + y = 17 + rise + facing = "east" if x <= 47 else "west" + if 38 <= x <= 57 and y >= 18: + for z in (16, 41): + v.box(x, 18, z, x, y, z, STONE) + v.box(x, y, 15, x, y, 42, _stair("deepslate_tile_stairs", facing)) + for z in (14, 43): + v.set(x, y, z, _stair("stone_brick_stairs", facing)) + if y > 17: + v.set(x, y - 1, z, TRIM) + v.box(47, 28, 15, 48, 28, 42, ROOF) + for z in (14, 43): + v.box(47, 27, z, 48, 28, z, LIGHT) + _lancet(v, 46, 49, 19, 24, 16, 15, north=True) + for x in (47, 48): + v.box(x, 26, 15, x, 28, 15, TRIM) + + # Wooden cornice brackets are visible under the long roof slopes. + for z in (19, 23, 27, 31, 35, 39): + for x, facing in ((37, "east"), (58, "west")): + v.set(x, 16, z, _stair("spruce_stairs", facing, half="top")) diff --git a/scripts/builds/gothic_hall/tower.py b/scripts/builds/gothic_hall/tower.py new file mode 100644 index 0000000..f5b6e7b --- /dev/null +++ b/scripts/builds/gothic_hall/tower.py @@ -0,0 +1,190 @@ +"""Reference bell tower, in the shared scene's inclusive local coordinates. + +The scene supplies set(x, y, z, block), box(x1, y1, z1, x2, y2, z2, +block), and clear(x1, y1, z1, x2, y2, z2). This module has no I/O. +All geometry stays inside x=34..46, y=0..60, z=42..56. +""" + + +STONE = "minecraft:stone_bricks" +TRIM = "minecraft:polished_andesite" +LIGHT = "minecraft:polished_diorite" +DARK = "minecraft:deepslate_tiles" +GLASS = "minecraft:tinted_glass" +AIR = "minecraft:air" + + +def _face(face, u, y, depth=0): + """Positive depth projects outward; -1 is the recessed glazing plane.""" + if face == "north": + return u, y, 43 - depth + if face == "south": + return u, y, 55 + depth + if face == "west": + return 35 - depth, y, u + return 45 + depth, y, u + + +def _put(v, face, u, y, block, depth=0): + v.set(*_face(face, u, y, depth), block) + + +def _ring(v, x1, z1, x2, z2, y, block): + v.box(x1, y, z1, x2, y, z1, block) + v.box(x1, y, z2, x2, y, z2, block) + v.box(x1, y, z1, x1, y, z2, block) + v.box(x2, y, z1, x2, y, z2, block) + + +def _lancet(v, face, center, bottom, top, half_width): + """A pointed opening through a two-block wall, glass one block behind it.""" + for y in range(bottom, top + 1): + radius = 0 if y >= top - 1 else half_width + for u in range(center - radius, center + radius + 1): + _put(v, face, u, y, AIR) + _put(v, face, u, y, GLASS, -1) + # The projecting dressings are stone; the glazing never projects. + for u in {center - radius - 1, center + radius + 1}: + _put(v, face, u, y, TRIM, 1) + for u in range(center - half_width - 1, center + half_width + 2): + _put(v, face, u, bottom - 1, "minecraft:stone_brick_slab[type=top]", 1) + _put(v, face, center, top + 1, LIGHT, 1) + + +def _belfry_arch(v, face, center): + # Seven blocks wide below, narrowing to a high, single-block apex. + for y in range(32, 41): + radius = 3 if y <= 37 else 40 - y + for u in range(center - radius, center + radius + 1): + for depth in (0, -1): + _put(v, face, u, y, AIR, depth) + for u in (center - radius - 1, center + radius + 1): + _put(v, face, u, y, TRIM, 1) + _put(v, face, center, 41, LIGHT, 1) + for u in range(center - 4, center + 5): + _put(v, face, u, 31, "minecraft:stone_brick_slab[type=top]", 1) + + +def _stairs(v): + # Three narrow alternating flights join the interior floors. A landing at + # each end leaves a three-block-wide central well open through the tower. + for y_floor, xs, reverse in ((1, (37, 38), False), + (10, (42, 43), True), + (19, (37, 38), False)): + facing = "north" if reverse else "south" + state = f"minecraft:stone_brick_stairs[facing={facing},half=bottom,shape=straight]" + for i in range(9): + z = 53 - i if reverse else 45 + i + y = y_floor + i + 1 + for x in xs: + # Clearance through the destination floor, including headroom. + v.clear(x, y + 1, z, x, y + 3, z) + v.set(x, y, z, state) + # The flight exits sideways onto the destination floor. + v.clear(39, 29, 53, 41, 32, 53) + v.set(41, 29, 52, "minecraft:stone_brick_stairs[facing=north]") + v.set(41, 30, 51, "minecraft:stone_brick_stairs[facing=north]") + v.clear(41, 30, 52, 41, 33, 52) + v.clear(41, 31, 51, 41, 33, 51) + + +def _spire(v): + # A hollow, steep rectangular hip roof; alternate contractions avoid a + # stack of disconnected floating rings while giving the spire a fine tip. + for y in range(45, 57): + step = y - 45 + rx = 5 - step // 2 + rz = 6 - (step + 1) // 2 + if rx < 0: + rx = 0 + if rz < 0: + rz = 0 + x1, x2, z1, z2 = 40 - rx, 40 + rx, 49 - rz, 49 + rz + v.box(x1, y, z1, x2, y, z2, DARK) + if rx > 0 and rz > 0: + v.clear(x1 + 1, y, z1 + 1, x2 - 1, y, z2 - 1) + # Stair edges soften the one-block roof steps without adding width. + if rx > 0 and rz > 0: + for x in range(x1 + 1, x2): + v.set(x, y, z1, "minecraft:deepslate_tile_stairs[facing=south]") + v.set(x, y, z2, "minecraft:deepslate_tile_stairs[facing=north]") + for z in range(z1 + 1, z2): + v.set(x1, y, z, "minecraft:deepslate_tile_stairs[facing=east]") + v.set(x2, y, z, "minecraft:deepslate_tile_stairs[facing=west]") + # The cross is plain masonry, with no unsupported entities or thin blocks. + v.box(40, 57, 49, 40, 60, 49, TRIM) + v.box(39, 59, 49, 41, 59, 49, TRIM) + + +def build(v): + """Add a hollow Gothic bell tower and its pointed roof to a scene.""" + # Foundation and shell: two-block walls give the apertures real reveals. + v.box(35, 0, 43, 45, 43, 55, STONE) + v.clear(37, 2, 45, 43, 42, 53) + _ring(v, 34, 42, 46, 56, 0, "minecraft:deepslate_bricks") + _ring(v, 34, 42, 46, 56, 1, TRIM) + _ring(v, 34, 42, 46, 56, 3, "minecraft:stone_brick_slab[type=bottom]") + + # Dressed corners and stepped buttress feet, all within the tower envelope. + corners = ((34, 42, 35, 43), (45, 42, 46, 43), + (34, 55, 35, 56), (45, 55, 46, 56)) + for x1, z1, x2, z2 in corners: + v.box(x1, 1, z1, x2, 5, z2, TRIM) + v.box(x1, 6, z1, x2, 29, z2, STONE) + for y in (6, 15, 28): + v.box(x1, y, z1, x2, y, z2, TRIM) + for x, z in ((35, 43), (45, 43), (35, 55), (45, 55)): + v.box(x, 4, z, x, 43, z, TRIM) + + # Strong horizontal courses separate the shaft from the open belfry. + for y in (15, 16, 29, 30, 42, 43): + _ring(v, 34, 42, 46, 56, y, TRIM if y % 2 else STONE) + for y in (10, 19, 28, 30): + v.box(37, y, 45, 43, y, 53, "minecraft:oak_planks") + if y != 30: + v.clear(39, y, 47, 41, y, 51) + v.box(35, 44, 43, 45, 44, 55, STONE) + _ring(v, 34, 42, 46, 56, 44, "minecraft:stone_brick_slab[type=bottom]") + + # Paired narrow lower lights and broader, pointed upper lancets. + for face, centers in (("north", (38, 42)), ("south", (38, 42)), + ("west", (47, 51)), ("east", (47, 51))): + for center in centers: + _lancet(v, face, center, 6, 12, 0) + _lancet(v, face, center, 19, 26, 1) + _belfry_arch(v, face, 40 if face in ("north", "south") else 49) + + # An actual walk-in doorway toward the wing; no decorative solid door. + v.clear(39, 2, 42, 41, 5, 44) + v.clear(40, 6, 42, 40, 6, 44) + for x in (38, 42): + v.box(x, 2, 42, x, 5, 42, TRIM) + v.set(39, 6, 42, TRIM) + v.set(41, 6, 42, TRIM) + v.set(40, 7, 42, LIGHT) + _stairs(v) + + # A block-built ochre bell echoes the reference using allowlisted sandstone. + # Its open mouth and central clapper remain visible through all four arches. + v.box(35, 41, 49, 45, 41, 49, "minecraft:spruce_log[axis=x]") + v.box(40, 38, 49, 40, 40, 49, "minecraft:dark_oak_log[axis=y]") + v.box(40, 36, 49, 40, 37, 49, "minecraft:cut_sandstone") + for x, z in ((39, 49), (40, 48), (40, 49), (40, 50), (41, 49)): + v.set(x, 35, z, "minecraft:cut_sandstone") + _ring(v, 39, 48, 41, 50, 34, "minecraft:cut_sandstone") + v.set(40, 34, 49, "minecraft:dark_oak_log[axis=y]") + v.set(40, 33, 49, TRIM) + + # A row of small corbels under the cornice is readable from a distance. + for face, us in (("north", (37, 39, 41, 43)), ("south", (37, 39, 41, 43)), + ("west", (45, 47, 49, 51, 53)), ("east", (45, 47, 49, 51, 53))): + for u in us: + _put(v, face, u, 41, f"minecraft:stone_brick_stairs[facing={face},half=top]", 1) + _spire(v) + + # Four light corner pinnacles frame the darker central spire. + for x, z in ((35, 43), (45, 43), (35, 55), (45, 55)): + v.box(x, 44, z, x, 49, z, TRIM) + for dx, dz in ((-1, 0), (1, 0), (0, -1), (0, 1)): + v.set(x + dx, 46, z + dz, "minecraft:stone_brick_slab[type=top]") + v.set(x, 50, z, "minecraft:stone_brick_slab[type=bottom]") diff --git a/scripts/camera-wrapper.py b/scripts/camera-wrapper.py new file mode 100644 index 0000000..0846bfb --- /dev/null +++ b/scripts/camera-wrapper.py @@ -0,0 +1,28 @@ +#!/usr/bin/env python3 +"""Prism Java wrapper: inject the camera secret without putting it in launcher arguments/logs.""" +import os +from pathlib import Path +import re +import sys + +ROOT = Path(__file__).resolve().parents[1] + +def main(): + if len(sys.argv) < 2: + raise SystemExit('This helper is a Prism WrapperCommand; it requires the Java command.') + config = Path(os.environ.get('MCB_CAMERA_PAPER_CONFIG', str(ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml'))) + try: + data = config.read_text() + token_match = re.search(r'^camera-token:[ \t]*([^\r\n]+)$', data, re.M) + port_match = re.search(r'^camera-port:[ \t]*(\d+)[ \t]*$', data, re.M) + token = token_match.group(1).strip().strip("'\"") if token_match else '' + port = int(port_match.group(1)) if port_match else 8766 + if not re.fullmatch(r'[A-Za-z0-9._~-]{32,512}', token) or not 1024 <= port <= 65535: + raise ValueError('Invalid camera configuration') + except (OSError, ValueError): + raise SystemExit('Cannot read a valid camera token/port from the private Paper config.') + environment = dict(os.environ, MCB_CAMERA_TOKEN=token, MCB_CAMERA_PORT=str(port)) + os.execvpe(sys.argv[1], sys.argv[1:], environment) + +if __name__ == '__main__': + main() diff --git a/scripts/dev-server.py b/scripts/dev-server.py new file mode 100644 index 0000000..f1b234f --- /dev/null +++ b/scripts/dev-server.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Prepare/run a private disposable Paper fixture. Never accepts Minecraft EULA implicitly.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import urllib.request + +ROOT=Path(__file__).resolve().parents[1] +CACHE=Path(os.environ.get('MCB_TOOL_CACHE', str(Path.home()/'.cache/minecraft-builder-mcp'))) +URL='https://fill-data.papermc.io/v1/objects/7b7b3b43c009103e1971a0576c26f655a7dd9b56a0a2a4438e352c03a7fecd08/paper-26.2-123.jar' +SHA='7b7b3b43c009103e1971a0576c26f655a7dd9b56a0a2a4438e352c03a7fecd08' + +def prepare(): + server=ROOT/'.runtime/server' + server.mkdir(parents=True, exist_ok=True) + jar=server/'paper.jar' + if not jar.exists(): + tmp=jar.with_suffix('.download') + request=urllib.request.Request(URL,headers={'User-Agent':'minecraft-builder-mcp/0.1 (local-development)'}) + with urllib.request.urlopen(request,timeout=60) as source, tmp.open('wb') as out: + shutil.copyfileobj(source,out) + tmp.replace(jar) + if hashlib.sha256(jar.read_bytes()).hexdigest()!=SHA: + raise SystemExit('Paper SHA256 mismatch; remove .runtime/server/paper.jar and retry') + plugin=ROOT/'paper-plugin/target/paper-plugin-0.1.0-SNAPSHOT.jar' + if not plugin.exists(): raise SystemExit('Build the plugin first: ./mvnw package') + (server/'plugins').mkdir(exist_ok=True) + shutil.copy2(plugin,server/'plugins/minecraft-builder-mcp.jar') + properties=server/'server.properties' + if not properties.exists(): + properties.write_text('server-ip=127.0.0.1\nserver-port=25575\nonline-mode=true\nlevel-name=world\nlevel-type=minecraft:flat\ngenerator-settings={"layers":[{"block":"minecraft:bedrock","height":1},{"block":"minecraft:dirt","height":2},{"block":"minecraft:grass_block","height":1}],"biome":"minecraft:plains"}\ngenerate-structures=false\nspawn-protection=0\nview-distance=5\nsimulation-distance=3\nmax-players=4\ngamemode=creative\ndifficulty=peaceful\nenable-rcon=false\nmotd=minecraft-builder-mcp private development world\n') + if not (server/'eula.txt').exists(): (server/'eula.txt').write_text('eula=false\n') + print(f'Prepared {server}; Paper SHA256 verified. No existing worlds modified.', flush=True) + return server + +if __name__=='__main__': + ap=argparse.ArgumentParser(description=__doc__) + ap.add_argument('--run',action='store_true') + ap.add_argument('--accept-eula',action='store_true',help='Explicitly accept https://www.minecraft.net/eula before starting this private test server') + args=ap.parse_args() + server=prepare() + if args.accept_eula: (server/'eula.txt').write_text('# Accepted explicitly by the operator for this development server.\neula=true\n') + if args.run: + if 'eula=true' not in (server/'eula.txt').read_text(): + raise SystemExit('Minecraft EULA acceptance required: https://www.minecraft.net/eula ; review then rerun with --accept-eula if you agree.') + java=Path(os.environ.get('MCB_JAVA_HOME',str(CACHE/'jdk-25.0.2')))/'bin/java' + if not java.exists(): raise SystemExit('Run python3 scripts/bootstrap-tools.py first') + raise SystemExit(subprocess.call([str(java),'-Dterminal.jline=false','-Dterminal.ansi=false','-Xms512M','-Xmx2G','-jar','paper.jar','--nogui'],cwd=server)) diff --git a/scripts/finish-gothic-hall.py b/scripts/finish-gothic-hall.py new file mode 100644 index 0000000..6d4e932 --- /dev/null +++ b/scripts/finish-gothic-hall.py @@ -0,0 +1,278 @@ +#!/usr/bin/env python3 +"""Prepare/apply 18 checked Gothic-hall corrections; verify the entire original build plus this patch.""" +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import tempfile +import time +import urllib.error +import urllib.request +import uuid + +from builds.gothic_hall.scene import build_scene + +ROOT = Path(__file__).resolve().parents[1] +OUTPUT = ROOT / '.runtime/gothic-hall' +CONFIG = ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml' +LEDGER = OUTPUT / 'finish-ledger.json' +STAIR = 'minecraft:stone_brick_stairs[facing=east,half=bottom,shape=straight,waterlogged=false]' + + +def require(condition, message): + if not condition: + raise RuntimeError(message) + + +def digest(value): + return hashlib.sha256(json.dumps(value, sort_keys=True, separators=(',', ':')).encode()).hexdigest() + + +def point(value): + return value['x'], value['y'], value['z'] + + +def position(values): + return dict(zip(('x', 'y', 'z'), values)) + + +def canonical(state): + if '[' not in state: + return state + name, properties = state.rstrip(']').split('[', 1) + return name + '[' + ','.join(sorted(properties.split(','))) + ']' + + +def envelope(path, kind): + data = json.loads(path.read_text()) + require(data.get('format') == 1 and data.get('kind') == kind, 'Unexpected journal envelope') + require(data.get('id') == path.stem, 'Journal identity mismatch') + require(hashlib.sha256(data['payload'].encode()).hexdigest() == data['sha256'], 'Journal checksum mismatch') + value = json.loads(data['payload']) + require(value['id'] == path.stem, 'Journal payload identity mismatch') + return value, data['sha256'] + + +def save(path, data, immutable=False): + """Fully persist a complete JSON file; immutable files are never replaced.""" + path.parent.mkdir(parents=True, exist_ok=True) + fd, temporary = tempfile.mkstemp(prefix='.finish-', suffix='.tmp', dir=path.parent) + try: + with os.fdopen(fd, 'w') as stream: + json.dump(data, stream, indent=2) + stream.write('\n') + stream.flush() + os.fsync(stream.fileno()) + if immutable: + os.link(temporary, path) # Atomic publication with fail-if-existing semantics. + else: + os.replace(temporary, path) + directory = os.open(path.parent, os.O_RDONLY) + try: + os.fsync(directory) + finally: + os.close(directory) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + raise RuntimeError('Paper RPC redirect refused') + + +class Backend: + def __init__(self): + config = CONFIG.read_text() + + def scalar(name): + match = re.search(r'^' + re.escape(name) + r':[ \t]*(.*?)$', config, re.M) + require(match is not None, 'Required Paper configuration field is missing') + return match.group(1).strip().strip("'\"") + + self.scope = {'player_id': scalar('owner-uuid'), 'project_id': scalar('project-id')} + require(self.scope['player_id'], 'An online bound owner is required') + self.token = scalar('agent-token') + port = scalar('http-port') + require(port.isdigit() and 1 <= int(port) <= 65535, 'Invalid Paper port') + self.url = 'http://127.0.0.1:' + port + '/v1/rpc' + self.opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + + def rpc(self, method, params): + for _ in range(100): + payload = {'method': method, 'params': {**params, **self.scope}, 'requestId': str(uuid.uuid4())} + request = urllib.request.Request(self.url, data=json.dumps(payload).encode(), headers={ + 'Authorization': 'Bearer ' + self.token, 'Content-Type': 'application/json'}) + try: + with self.opener.open(request, timeout=30) as response: + raw = response.read(2_097_153) + except urllib.error.HTTPError as error: + raw = error.read(2_097_153) + except (OSError, urllib.error.URLError): + raise RuntimeError(f'{method}: connection interrupted; reuse the existing finish ledger') from None + require(len(raw) <= 2_097_152, 'Paper response exceeded limit') + body = json.loads(raw) + if body.get('ok'): + return body['result'] + code = body.get('error', {}).get('code', 'unknown') + if code == 'busy': + time.sleep(.1) + continue + raise RuntimeError(f'{method}: {code}; no blind overwrite or new idempotency key was used') + raise RuntimeError('Paper remained busy') + + +def references(): + model = json.loads((OUTPUT / 'manifest.json').read_text()) + base = json.loads((OUTPUT / 'ledger.json').read_text()) + require(base['manifest_sha256'] == digest(model), 'Original manifest no longer matches its ledger') + require(model['blocks'] == 29354 and len(model['batches']) == len(base['batches']) == 87, + 'Expected the original completed 29,354-block, 87-plan build') + require({b['key'] for b in model['batches']} == set(base['batches']), 'Original batch set changed') + states, plans = {}, {} + for batch in model['batches']: + record = base['batches'][batch['key']] + require(record.get('status', {}).get('status') == 'applied', 'Original build has an incomplete batch') + summary = record['plan'] + plan, checksum = envelope(CONFIG.parent / 'journal/plans' / (summary['plan_id'] + '.json'), 'plan') + require(checksum == summary['plan_hash'], 'Original saved plan hash changed') + require(plan['worldEpoch'] == model['world_epoch'] and plan['region']['worldId'] == model['world_id'], + 'Original plan world identity differs') + require(len(plan['changes']) == batch['blocks'], 'Original plan block count differs') + for change in plan['changes']: + at = point(change['pos']) + require(at not in states, 'Original plans overlap') + states[at] = change['desired'] + plans[batch['key']] = {'plan_id': plan['id'], 'sha256': checksum, 'plan': plan} + require(len(states) == model['blocks'], 'Original saved plans are incomplete') + return model, states, plans + + +def targets(model, states): + scene = build_scene() + local = [(x, 30, z) for x in (6, 32) for z in (14, 22, 30, 38, 46, 54)] + local += [(x, 34, 12) for x in (7, 31)] + local += [(35, 8, z) for z in range(36, 40)] + require(len(local) == len(set(local)) == 18, 'Finish mask must contain exactly 18 cells') + result = [] + for at in local: + world = tuple(a + b for a, b in zip(at, model['origin'])) + before = states.get(world) + require(before is not None and canonical(scene.blocks.get(at, 'minecraft:air')) == canonical(before), + f'Frozen scene differs from original canonical plan at local {at}') + desired = STAIR if at[0] == 35 else 'minecraft:stone_bricks' + if at[0] != 35: + require(before == 'minecraft:stone_brick_slab[type=bottom,waterlogged=false]', + f'Expected the original pinnacle cap at local {at}') + else: + require(before == 'minecraft:spruce_planks', f'Expected the original gallery floor at local {at}') + result.append({'local': list(at), 'pos': position(world), 'expected': before, 'desired': desired}) + return result + + +def inspect_patch(backend, changes, field): + for group in ([c for c in changes if c['local'][1] == y] for y in (30, 34, 8)): + locations = [point(c['pos']) for c in group] + lo = position([min(p[i] for p in locations) for i in range(3)]) + hi = position([max(p[i] for p in locations) for i in range(3)]) + result = backend.rpc('region_inspect', {'min': lo, 'max': hi, 'detail': 'blocks'}) + live = {point(b['pos']): b['state'] for b in result['blocks']} + for change in group: + require(live.get(point(change['pos'])) == change[field], + f'Live {field} mismatch at local {tuple(change["local"])}; preserve current world') + + +def validate_saved_finish(ledger, changes, model, plans): + require(ledger['version'] == 1 and ledger['manifest_sha256'] == digest(model), 'Finish ledger blueprint mismatch') + require(ledger['changes'] == changes, 'Immutable finish mask changed') + sources = {key: {'plan_id': value['plan_id'], 'sha256': value['sha256']} for key, value in plans.items()} + require(ledger['source_plans'] == sources, 'Original plan references changed') + plan, checksum = envelope(CONFIG.parent / 'journal/plans' / (ledger['plan']['plan_id'] + '.json'), 'plan') + require(checksum == ledger['plan']['plan_hash'], 'Prepared finish plan hash mismatch') + require(plan['worldEpoch'] == model['world_epoch'] and plan['region']['worldId'] == model['world_id'], + 'Finish plan world identity changed') + expected = {point(c['pos']): (c['expected'], c['desired']) for c in changes} + actual = {point(c['pos']): (c['expected'], c['desired']) for c in plan['changes']} + require(len(plan['changes']) == 18 and actual == expected, + 'Prepared expected/desired states differ from reviewed original states; do not apply') + + +def operation_id(ledger): + matches = [] + for path in (CONFIG.parent / 'journal/operations').glob('*.json'): + operation, _ = envelope(path, 'operation') + if operation['planId'] == ledger['plan']['plan_id'] and operation['idempotencyKey'] == ledger['idempotency_key']: + matches.append(operation['id']) + require(len(matches) <= 1, 'Multiple operations share the finish identity') + return matches[0] if matches else None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('action', choices=['plan', 'apply', 'verify']) + args = parser.parse_args() + model, originals, plans = references() + changes = targets(model, originals) + backend = Backend() + context = backend.rpc('project_context', {}) + require(context['world_id'] == model['world_id'] and context['world_epoch'] == model['world_epoch'], + 'Live world identity changed') + if args.action == 'plan' and not LEDGER.exists(): + inspect_patch(backend, changes, 'expected') + recipe = {'version': 1, 'operations': [ + {'type': 'box', 'min': c['pos'], 'max': c['pos'], 'block': c['desired']} for c in changes]} + summary = backend.rpc('build_prepare', {'recipe': recipe}) + ledger = {'version': 1, 'manifest_sha256': digest(model), 'scope': backend.scope, + 'source_plans': {k: {'plan_id': p['plan_id'], 'sha256': p['sha256']} for k, p in plans.items()}, + 'changes': changes, 'plan': summary, 'idempotency_key': 'gothic-hall-finish-' + summary['plan_id']} + validate_saved_finish(ledger, changes, model, plans) + save(LEDGER, ledger, immutable=True) + require(LEDGER.is_file(), 'Run plan first; finish-ledger.json must be durably saved before applying') + ledger = json.loads(LEDGER.read_text()) + validate_saved_finish(ledger, changes, model, plans) + require(ledger['scope'] == backend.scope, 'Finish owner/project scope changed') + if args.action == 'plan': + print(json.dumps({'status': 'prepared', 'changes': 18, 'plan_id': ledger['plan']['plan_id'], + 'expires_at': ledger['plan']['expires_at'], 'immutable_ledger': str(LEDGER)})) + return + ident = operation_id(ledger) + if args.action == 'apply': + # Always keep the persisted key. A lost response must never cause a fresh operation. + if ident is None: + result = backend.rpc('build_apply', {**ledger['plan'], 'idempotency_key': ledger['idempotency_key']}) + ident = result['operation_id'] + for _ in range(600): + status = backend.rpc('operation_status', {'operation_id': ident}) + if status['status'] not in ('queued', 'applying'): + break + time.sleep(.1) + require(status['status'] == 'applied', f'Finish operation stopped: {status["status"]}; inspect before continuing') + inspect_patch(backend, changes, 'desired') + print(json.dumps({'status': 'applied', 'changes': 18, 'written': status['written'], 'operation_id': ident})) + return + require(ident is not None, 'Finish operation has not been applied') + status = backend.rpc('operation_status', {'operation_id': ident}) + require(status['status'] == 'applied', 'Finish operation is not fully applied') + final = dict(originals) + final.update({point(c['pos']): c['desired'] for c in changes}) + for batch in model['batches']: + result = backend.rpc('region_inspect', {'min': batch['min'], 'max': batch['max'], 'detail': 'blocks'}) + live = {point(b['pos']): b['state'] for b in result['blocks']} + for change in plans[batch['key']]['plan']['changes']: + at = point(change['pos']) + require(live.get(at) == final[at], f'Final build differs at world {at}; preserve current world') + report = {'status': 'verified', 'blocks': len(final), 'batches': len(model['batches']), 'finish_changes': 18, + 'operation_id': ident, 'manifest_sha256': digest(model), 'finish_ledger_sha256': digest(ledger), + 'verified_at': time.time(), 'scope': backend.scope} + save(OUTPUT / 'finish-verification.json', report) + print(json.dumps(report)) + + +if __name__ == '__main__': + try: + main() + except (OSError, ValueError, KeyError, RuntimeError) as error: + raise SystemExit('Finish stopped: ' + str(error)) from None diff --git a/scripts/live-camera-test.py b/scripts/live-camera-test.py new file mode 100755 index 0000000..f2d8713 --- /dev/null +++ b/scripts/live-camera-test.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Capture a real PNG through Paper and the Fabric worker using one online owner/spectator. + +Does not start Minecraft/Paper, change configuration, or fabricate/alter images. By default, +uses the pose saved in game with /ai camera save test. Run with --delay 8 and return to the +Minecraft window, close menus, and keep the player still until the capture completes. +""" +import argparse +import base64 +import binascii +from datetime import datetime, timezone +import hashlib +import json +import math +import os +from pathlib import Path +import re +import struct +import sys +import time +import urllib.error +import urllib.request +import uuid +import zlib + +ROOT = Path(__file__).resolve().parents[1] +MAX_RESPONSE = 12 * 1024 * 1024 +PNG_SIGNATURE = b'\x89PNG\r\n\x1a\n' +SAFE_METADATA = { + 'status', 'captureId', 'capturedAt', 'dimension', 'x', 'y', 'z', 'eyeY', 'yaw', 'pitch', + 'fov', 'readiness', 'serverRevisionVerified', 'loadedChunkRadius', 'stabilizationTicks', + 'stabilizationFrames', 'afterOperationId', 'width', 'height', 'sourceWidth', 'sourceHeight', + 'mimeType', +} + + +class TestFailure(Exception): + """Messages are selected locally and never contain raw HTTP bodies or header values.""" + + +class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, request, response, code, message, headers, new_url): + raise TestFailure('Refusing an HTTP redirect from the local camera/Paper endpoint.') + + +def scalar(text, key): + matches = re.findall(r'^' + re.escape(key) + r':[ \t]*(.*?)[ \t]*$', text, re.M) + if len(matches) != 1: + raise TestFailure('Missing or duplicate top-level configuration field: ' + key) + raw = matches[0].strip() + if raw.startswith("'"): + match = re.fullmatch(r"'((?:[^']|'')*)'[ \t]*(?:#.*)?", raw) + if match: + return match.group(1).replace("''", "'") + elif raw.startswith('"'): + match = re.fullmatch(r'("(?:[^"\\]|\\.)*")[ \t]*(?:#.*)?', raw) + if match: + try: + value = json.loads(match.group(1)) + if isinstance(value, str): + return value + except (ValueError, TypeError): + pass + else: + return re.split(r'[ \t]+#', raw, maxsplit=1)[0].strip() + raise TestFailure('Use a single Paper-generated scalar for configuration field: ' + key) + + +def identifier(value, label): + try: + parsed = str(uuid.UUID(value)) + except (ValueError, TypeError, AttributeError): + raise TestFailure(label + ' must be configured as a UUID.') from None + return parsed + + +def port(value, label): + if not value.isdigit() or not 1 <= int(value) <= 65535: + raise TestFailure(label + ' must be a port in 1..65535.') + return int(value) + + +def instant(value, label): + try: + parsed = datetime.fromisoformat(value.replace('Z', '+00:00')) + if parsed.tzinfo is None: + raise ValueError() + return parsed.timestamp() + except (ValueError, TypeError, AttributeError): + raise TestFailure(label + ' is missing or invalid.') from None + + +def request_json(opener, url, token, body=None, timeout=10): + headers = {'Authorization': 'Bearer ' + token, 'Accept': 'application/json'} + if body is not None: + headers['Content-Type'] = 'application/json' + request = urllib.request.Request(url, headers=headers, + data=None if body is None else json.dumps(body, allow_nan=False).encode('utf-8'), + method='GET' if body is None else 'POST') + try: + with opener.open(request, timeout=timeout) as response: + raw = response.read(MAX_RESPONSE + 1) + if len(raw) > MAX_RESPONSE: + raise TestFailure('Backend response exceeds the 12 MiB limit.') + value = json.loads(raw) + if not isinstance(value, dict): + raise TestFailure('Backend returned an invalid JSON object.') + return value + except urllib.error.HTTPError as error: + # Do not relay the body, reason, request object, headers, or arbitrary exception text. + raise TestFailure('Local endpoint rejected the request (HTTP ' + str(error.code) + ').') from None + except (urllib.error.URLError, TimeoutError, ConnectionError, OSError): + raise TestFailure('Local endpoint is unavailable or timed out; check the running Paper/Fabric services.') from None + except (json.JSONDecodeError, UnicodeError): + raise TestFailure('Local endpoint returned invalid JSON.') from None + + +def rpc(opener, endpoint, token, scope, method, params=None, timeout=10): + reply = request_json(opener, endpoint + '/v1/rpc', token, + {'method': method, 'params': {**(params or {}), **scope}}, timeout=timeout) + if reply.get('ok') is not True or not isinstance(reply.get('result'), dict): + code = reply.get('error', {}).get('code', '') if isinstance(reply.get('error'), dict) else '' + safe_code = code if isinstance(code, str) and re.fullmatch(r'[a-z_]{1,64}', code) else 'request_failed' + raise TestFailure('Paper rejected the scoped request: ' + safe_code) + return reply['result'] + + +def check_health(health, owner): + if health.get('status') != 'ok' or health.get('connected') is not True: + raise TestFailure('The Fabric camera is not connected to a world.') + if health.get('spectator') is not True: + raise TestFailure('The owner must already be in spectator mode; this script does not change game modes.') + if identifier(health.get('playerId'), 'Worker player ID') != owner: + raise TestFailure('The worker is using a different account from the configured owner.') + age = time.time() - instant(health.get('updatedAt'), 'Worker heartbeat') + if age < -5 or age > 5: + raise TestFailure('The worker heartbeat is stale; ensure Minecraft is ticking and the clocks agree.') + if health.get('busy') is True: + raise TestFailure('The camera is already busy; finish its current capture before running the test.') + + +def validate_pose(values): + x, y, z, yaw, pitch = values + if not all(math.isfinite(value) for value in values): + raise TestFailure('Pose values must all be finite numbers.') + if abs(x) > 29_999_984 or abs(z) > 29_999_984 or not -2048 <= y <= 2048: + raise TestFailure('Pose is outside the camera coordinate limits.') + if not -360 <= yaw <= 360 or not -90 <= pitch <= 90: + raise TestFailure('Yaw must be -360..360 and pitch -90..90.') + return dict(zip(('x', 'y', 'z', 'yaw', 'pitch'), values)) + + +def inspect_png(raw): + """Verify bytes received from the worker, without re-encoding or generating an image.""" + if not raw.startswith(PNG_SIGNATURE) or len(raw) > 8 * 1024 * 1024: + raise TestFailure('Worker did not return a bounded PNG image.') + offset, dimensions, saw_pixels = 8, None, False + while offset + 12 <= len(raw): + length = struct.unpack_from('>I', raw, offset)[0] + kind = raw[offset + 4:offset + 8] + if length > len(raw) - offset - 12: + raise TestFailure('PNG chunk is truncated.') + data = raw[offset + 8:offset + 8 + length] + expected = struct.unpack_from('>I', raw, offset + 8 + length)[0] + if zlib.crc32(kind + data) & 0xffffffff != expected: + raise TestFailure('PNG checksum does not match the received data.') + if offset == 8: + if kind != b'IHDR' or length != 13: + raise TestFailure('PNG has no valid IHDR header.') + dimensions = struct.unpack_from('>II', data) + if not 1 <= dimensions[0] <= 1920 or not 1 <= dimensions[1] <= 1080: + raise TestFailure('PNG dimensions exceed the output limits.') + if kind == b'IDAT': + saw_pixels = True + offset += length + 12 + if kind == b'IEND': + if length != 0 or offset != len(raw) or not saw_pixels: + raise TestFailure('PNG is incomplete or contains trailing data.') + return dimensions + raise TestFailure('PNG does not contain a complete image.') + + +def write_private(path, data): + fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600) + with os.fdopen(fd, 'wb') as out: + out.write(data) + out.flush() + os.fsync(out.fileno()) + + +def run(args): + try: + text = args.config.read_text(encoding='utf-8') + except (OSError, UnicodeError): + raise TestFailure('Cannot read the private Paper config; start/configure the plugin first.') from None + owner = identifier(scalar(text, 'owner-uuid'), 'Owner UUID') + camera_player = identifier(scalar(text, 'camera-player-uuid'), 'Camera player UUID') + if owner != camera_player: + raise TestFailure('This single-client test requires camera-player-uuid to equal owner-uuid.') + agent_token, camera_token = scalar(text, 'agent-token'), scalar(text, 'camera-token') + if any(not re.fullmatch(r'[A-Za-z0-9._~-]{32,512}', value) for value in (agent_token, camera_token)) or agent_token == camera_token: + raise TestFailure('Agent and camera tokens must be distinct safe values; contents are not displayed.') + project = scalar(text, 'project-id') + if not project or len(project) > 256: + raise TestFailure('A valid project-id is required.') + endpoint = 'http://127.0.0.1:' + str(port(scalar(text, 'http-port'), 'http-port')) + worker = 'http://127.0.0.1:' + str(port(scalar(text, 'camera-port'), 'camera-port')) + scope = {'project_id': project, 'player_id': owner} + # Never let HTTP_PROXY/HTTPS_PROXY forward private capability headers away from loopback. + opener = urllib.request.build_opener(urllib.request.ProxyHandler({}), NoRedirect()) + health = request_json(opener, worker + '/health', camera_token) + check_health(health, owner) + # This authenticates the real in-game owner and confirms the configured Paper server is reachable. + context = rpc(opener, endpoint, agent_token, scope, 'project_context') + if context.get('project_id') != project: + raise TestFailure('Paper returned a different project scope.') + if args.pose: + params = {'pose': validate_pose(args.pose)} + if args.fov is not None: + params['pose']['fov'] = args.fov + pose_label = 'explicit pose' + else: + cameras = rpc(opener, endpoint, agent_token, scope, 'camera_list') + if not any(isinstance(camera, dict) and camera.get('camera_id') == args.camera_id for camera in cameras.get('cameras', [])): + raise TestFailure('Saved camera was not found. In Minecraft run /ai camera save test, or pass --pose X Y Z YAW PITCH.') + params = {'camera_id': args.camera_id} + pose_label = 'saved camera' + if args.after_operation_id: + params['after_operation_id'] = identifier(args.after_operation_id, 'Operation ID') + print('Ready: online owner/spectator and scoped Paper route verified; using ' + pose_label + '.', flush=True) + print('Return to Minecraft, close chat/menus, keep the window rendering, and do not move the mouse or player. Capture starts in ' + + str(args.delay) + ' seconds.', flush=True) + time.sleep(args.delay) + check_health(request_json(opener, worker + '/health', camera_token), owner) + started = time.time() + deadline = time.monotonic() + args.timeout + result = rpc(opener, endpoint, agent_token, scope, 'camera_capture', params, timeout=min(35, args.timeout)) + capture_id = identifier(result.get('captureId'), 'Capture ID') + print('Paper accepted the capture; waiting for the real rendered frame.', flush=True) + while result.get('status') == 'pending': + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TestFailure('Capture did not complete within the test deadline; no image was saved.') + time.sleep(min(0.5, remaining)) + result = rpc(opener, endpoint, agent_token, scope, 'camera_capture', {'capture_id': capture_id}, timeout=min(10, max(0.5, remaining))) + if result.get('status') != 'completed': + code = result.get('error', '') + safe_code = code if isinstance(code, str) and re.fullmatch(r'[a-z_]{1,64}', code) else 'capture_failed' + raise TestFailure('The camera returned ' + safe_code + '; no image was saved.') + if result.get('captureId') != capture_id or result.get('mimeType') != 'image/png': + raise TestFailure('Completed capture identity or MIME type did not match.') + captured_at = instant(result.get('capturedAt'), 'Capture timestamp') + if captured_at < started - 1 or captured_at > time.time() + 5: + raise TestFailure('The frame timestamp does not correspond to this capture request.') + encoded = result.get('imageBase64') + if not isinstance(encoded, str) or len(encoded) > 12_000_000: + raise TestFailure('Completed capture did not include a bounded image payload.') + try: + raw = base64.b64decode(encoded, validate=True) + except (ValueError, binascii.Error): + raise TestFailure('Completed capture contained invalid image encoding.') from None + width, height = inspect_png(raw) + if result.get('width') != width or result.get('height') != height: + raise TestFailure('PNG dimensions disagree with the capture metadata.') + metadata = {key: value for key, value in result.items() if key in SAFE_METADATA} + # Include only known primitive fields; a backend must not smuggle an arbitrary nested payload here. + if any(not isinstance(value, (str, int, float, bool, type(None))) for value in metadata.values()): + raise TestFailure('Capture metadata contains unexpected structured values.') + metadata.update(imageSha256=hashlib.sha256(raw).hexdigest(), imageBytes=len(raw), elapsedSeconds=round(time.time() - started, 3), + transport='authenticated Paper HTTP camera_capture', testMode='one owner/spectator client') + safe_json = json.dumps(metadata, ensure_ascii=False, allow_nan=False, indent=2).encode('utf-8') + b'\n' + if agent_token.encode() in safe_json or camera_token.encode() in safe_json: + raise TestFailure('Refusing to save metadata containing a component secret.') + directory = ROOT / '.runtime/camera-test' + directory.mkdir(parents=True, exist_ok=True, mode=0o700) + prefix = datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + '-' + capture_id[:8] + image_path, metadata_path = directory / (prefix + '.png'), directory / (prefix + '.json') + write_private(image_path, raw) + write_private(metadata_path, safe_json) + print('Saved real PNG: ' + str(image_path), flush=True) + print('Saved sanitized metadata: ' + str(metadata_path), flush=True) + print('Frame size: ' + str(width) + 'x' + str(height) + '. Server revision verified: ' + + str(result.get('serverRevisionVerified') is True).lower() + '.', flush=True) + return 0 + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument('--config', type=Path, default=ROOT / '.runtime/server/plugins/MinecraftBuilderMCP/config.yml') + source = parser.add_mutually_exclusive_group() + source.add_argument('--camera-id', default='test', help='Saved in-game camera name; default: test') + source.add_argument('--pose', type=float, nargs=5, metavar=('X', 'Y', 'Z', 'YAW', 'PITCH'), help='Explicit feet position and viewing angles') + parser.add_argument('--fov', type=int, help='Field of view for an explicit --pose (30..110 degrees)') + parser.add_argument('--after-operation-id', help='Optional completed operation UUID for Paper validation and correlation') + parser.add_argument('--delay', type=int, default=8, help='Seconds to return to the Minecraft window before capture (0..60)') + parser.add_argument('--timeout', type=int, default=45, help='Total capture/poll budget in seconds (20..120)') + args = parser.parse_args() + if args.fov is not None and (not args.pose or not 30 <= args.fov <= 110): + parser.error('--fov requires --pose and must be 30..110') + if not 0 <= args.delay <= 60 or not 20 <= args.timeout <= 120: + parser.error('--delay must be 0..60 and --timeout must be 20..120') + if args.camera_id and (not args.camera_id.strip() or len(args.camera_id) > 64): + parser.error('--camera-id must contain 1..64 characters') + try: + return run(args) + except TestFailure as error: + print('Camera test failed: ' + str(error), file=sys.stderr) + return 1 + except KeyboardInterrupt: + print('Camera test interrupted; the worker will expire its current request.', file=sys.stderr) + return 130 + except Exception: + # Unexpected errors can include HTTP header details; never print a traceback with private state. + print('Camera test failed unexpectedly; no private configuration or raw exception is printed.', file=sys.stderr) + return 1 + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/scripts/live-server-test.py b/scripts/live-server-test.py new file mode 100644 index 0000000..59d28f4 --- /dev/null +++ b/scripts/live-server-test.py @@ -0,0 +1,188 @@ +#!/usr/bin/env python3 +"""Actual Paper integration tests in the disposable .runtime/server fixture (EULA must already be accepted).""" +import json +import os +from pathlib import Path +import re +import signal +import subprocess +import time +import urllib.request +import urllib.error + +ROOT=Path(__file__).resolve().parents[1] +SERVER=ROOT/'.runtime/server' +TOKEN='' +ADMIN_TOKEN='' +SCOPE={'player_id':'console','project_id':'default'} + +def request(method, params=None, expect_error=False, admin=False): + payload={'method':method,'params':{**(params or {}),**SCOPE},'requestId':'live-test'} + req=urllib.request.Request('http://127.0.0.1:8765/v1/rpc',data=json.dumps(payload).encode(),headers={'Authorization':'Bearer '+(ADMIN_TOKEN if admin else TOKEN),'Content-Type':'application/json'}) + try: + with urllib.request.urlopen(req,timeout=25) as r: body=json.load(r) + except urllib.error.HTTPError as e: body=json.load(e) + if expect_error: + assert not body['ok'],body + return body['error'] + assert body['ok'],body + return body['result'] + +def pos(x=8,y=96,z=8):return {'x':x,'y':y,'z':z} +def inspect(at):return request('region_inspect',{'min':at,'max':at,'detail':'blocks'})['blocks'][0]['state'] +def prepare(lo=None,hi=None,block='minecraft:stone_bricks',dependencies=None): + return request('build_prepare',{'recipe':{'version':1,'operations':[{'type':'box','min':lo or pos(),'max':hi or pos(10,98,10),'block':block}]},'dependencies':dependencies or []}) +def apply(plan,key):return request('build_apply',{'plan_id':plan['plan_id'],'plan_hash':plan['plan_hash'],'idempotency_key':key}) +def wait(predicate,timeout=30): + end=time.monotonic()+timeout + while time.monotonic()0 and s['status']=='applying' else None + progress=wait(in_progress) + os.killpg(process.pid,signal.SIGKILL);process.wait(timeout=10) + time.sleep(.5) + # Simulate an administrator protecting an affected part while offline. + # Recovery must retain authorization without requiring write permission. + metadata=json.loads(previous_metadata) if previous_metadata else {'parts':[],'cameras':{}} + metadata['parts'].append({'id':'live-recovery-protection','name':'Recovery test protection', + 'operationId':op['operation_id'],'positions':[lo],'locked':True}) + metadata_path.write_text(json.dumps(metadata)) + process=launch() + wait(lambda:urllib.request.urlopen('http://127.0.0.1:8765/health',timeout=1).status==200,90) + console('forceload add 0 0 31 31') + recovered=wait(lambda:terminal(op));assert recovered['status']=='recovery_required',recovered + review=request('recovery_review',{'operation_id':op['operation_id']},admin=True) + request('recovery_review',{'operation_id':op['operation_id']},expect_error=True) + console('setblock 8 100 8 minecraft:granite');wait(lambda:inspect(lo)=='minecraft:granite') + request('recovery_abandon',{'operation_id':op['operation_id'],'expected_digest':review['currentDigest']},expect_error=True,admin=True) + fresh=request('recovery_review',{'operation_id':op['operation_id']},admin=True) + abandoned=request('recovery_abandon',{'operation_id':op['operation_id'],'expected_digest':fresh['currentDigest']},admin=True) + assert abandoned['status']=='failed' and inspect(lo)=='minecraft:granite',abandoned + request('operation_undo_prepare',{'operation_id':op['operation_id']},expect_error=True) + report('crash-recovery-abandon',written_before_crash=progress['written'],recovery_positions=fresh['positions'],foreign_block='preserved',replay=False,protected_part='recovery-allowed') + + console('fill 8 100 8 23 115 23 minecraft:air') + wait(lambda:request('region_inspect',{'min':lo,'max':hi,'detail':'summary'})['palette']=={'minecraft:air':4096}) + protected_plan=prepare(lo,lo,block='minecraft:stone') + blocked=request('build_apply',{'plan_id':protected_plan['plan_id'],'plan_hash':protected_plan['plan_hash'], + 'idempotency_key':'live-protected-'+protected_plan['plan_id']},expect_error=True) + assert blocked['code']=='permission_denied' and inspect(lo)=='minecraft:air',blocked + report('recovery-retains-part-write-protection',status='permission_denied') + plan=prepare(pos(9,100,8),pos(9,100,8));new=wait(lambda:terminal(apply(plan,'live-after-recovery-'+plan['plan_id']))) + assert new['status']=='applied',new + undo=request('operation_undo_prepare',{'operation_id':new['operation_id']}) + assert wait(lambda:terminal(apply(undo,'live-after-recovery-undo-'+undo['plan_id'])))['status']=='applied' + report('editing-after-durable-recovery',status='applied-and-undone') + # Exercise the actual MCP stdio transport, including .schem roundtrip. + smoke_env=dict(os.environ,MCB_AGENT_TOKEN=TOKEN,MCB_PLAYER_ID='console', + MCB_PROJECT_ID='default',MCB_TEST_X='8',MCB_TEST_Y='96',MCB_TEST_Z='8', + MCB_BACKEND_URL='http://127.0.0.1:8765') + smoke_env.pop('MCB_TOKEN',None) + smoke=subprocess.run(['node','test/live-paper.mjs'],cwd=ROOT/'bridge',env=smoke_env, + text=True,stdout=subprocess.PIPE,stderr=subprocess.STDOUT,timeout=90) + (ROOT/'.runtime/live-mcp-results.log').write_text(smoke.stdout) + print(smoke.stdout,flush=True) + assert smoke.returncode==0,'Live MCP smoke failed; inspect .runtime/live-mcp-results.log' + report('mcp-stdio-schematic-roundtrip',status='passed') + finally: + if process.poll() is None: + try:console('stop');process.wait(timeout=30) + except (OSError,subprocess.TimeoutExpired):process.terminate();process.wait(timeout=15) + log.close();config.write_text(previous) + if previous_metadata is None:metadata_path.unlink(missing_ok=True) + else:metadata_path.write_bytes(previous_metadata) + (ROOT/'.runtime/live-server-results.json').write_text(json.dumps(results,indent=2)+'\n') + print('LIVE PAPER CONFLICT/UNDO/CANCEL/CRASH TESTS PASSED',flush=True) diff --git a/world-core/README.md b/world-core/README.md new file mode 100644 index 0000000..7b95f31 --- /dev/null +++ b/world-core/README.md @@ -0,0 +1,61 @@ +# world-core + +Java 17 world editing core with no Paper/Fabric dependency. It provides immutable plans, bounded preflight and write slices, optimistic conflict checks, confirmed write receipts, checked undo, and durable write-ahead intents. `RecipeCompiler` implements the version 1 `box`, `line`, `cylinder`, and nested `repeat` subset. + +## Integration contract + +One `EditEngine` belongs to one world and has one active operation at a time. `WorldAccess` must return canonical full state strings; the adapter supplies `BlockPolicy` and a `ContextGuard` that verifies the current project, region, world epoch, permissions, and chunk availability. Both original and desired states must be supported. The adapter must exclude inventories, entities, physics, and compound blocks whose secondary changes cannot be described by a single block write. + +World access runs on the server thread. Use one IO executor for durable methods. No IO method holds the engine monitor while writing or syncing files, so status and cancellation remain responsive. The adapter must serialize IO and avoid staging/committing while its IO task is pending. + +1. Server thread: `prepare(projectId, epoch, region, desired, dependencies)` captures a bounded snapshot. It does not write the world. +2. IO executor: `persistPlan(plan.id())`. +3. Server thread: `start(plan.id(), idempotencyKey)` creates an operation. Reusing the same key for the same immutable plan returns the original operation ID; another plan is rejected. Keys are scoped to a project. +4. IO executor: `flushOperation(operationId)` before returning a durable operation ID. +5. Server tick: `stageSlice(operationId)`. An empty result can mean preflight/final verification is still in progress. Check `status` and persist it if `needsFlush` is true. Preflight scans the whole request in bounded steps before any world write; changes can still arise after that scan. +6. When a slice is returned, IO executor: `persistIntent(intent)`. +7. Server thread: `commitSlice(intent)`. The engine rechecks the journaled before values, dependencies, context, and block policy after IO. It then writes and reads back at most the configured block limit, checking elapsed time between blocks. +8. IO executor: `flushOperation(operationId)` before another slice. Repeat steps 5–8 until terminal. `APPLIED` follows a bounded final live verification. + +If cancellation arrives while a state is being persisted, a newer cancellation flag leaves `needsFlush` true; flush it before another stage. Stop requests retain already confirmed changes. An intent is an internal object capability: the exact instance must pass through persistence and commit, and must never be deserialized from a remote request. + +`prepareUndo` only includes confirmed actual writes. Blocks already at the desired state are skipped and never claimed as this operation's work. Undo rejects a changed current state and any known later operation at the same position, even if its current value happens to equal the older result. `receipts` is for adapter bookkeeping, such as named part masks; it is potentially large and should not be returned to the model. + +Dependencies are explicitly supplied positions, limited separately from writes. Each slice rechecks all dependencies against their original values, or this operation's confirmed desired values when it has modified those positions. The engine does not infer structural supports from geometry. Final verification and slices are observations across ticks, not an atomic transaction for an entire building. + +Use `pendingOperations()` for scheduling and cancellation loops: a maintained index contains only active operations and terminal operations still needing persistence, so each tick does not scan completed history. Use `recentOperations(limit)` (0–100) and `operationCount()` for bounded context; `operations()` is a full-history administrative snapshot. Recent operations are ordered by in-process creation, with persisted plan-capture time used as an approximation after restart. Status views never copy full receipts. + +The adapter can call `recordExternal(position)` for observed successful external edits on the server thread. It invalidates known ownership even if a player changes a block away and back to the same state, so undo rejects that known later intervention. Do not call it for the engine's own writes or cancelled player events. Notifications allocate no history for unowned positions and do not write files in events. They are **ephemeral in this prototype**: after restart, unknown external edits retain only the live content-check guarantee. Persisted block-event revisions remain future journal work. + +## Prototype journal and recovery + +`JsonJournal` uses one checksummed JSON file per plan and operation, flushed file contents, atomic replacement, and directory fsync. This initial implementation deliberately uses atomic JSON snapshots instead of the design document's planned SQLite index and compressed chunk files. The host filesystem must support those durability primitives; there is no silent fallback to a weaker rename. Only one server process may own a journal directory. + +Plans and idempotency records survive restart. Any queued/applying operation, unfinished intent, or previous uncertain result becomes `RECOVERY_REQUIRED` on load and blocks all new writes in that engine. No intent is replayed automatically. `inspectRecovery(id, offset, limit)` reads a bounded page of uncertain before/after/current states; matching after is evidence of content, not proof of authorship. Malformed or corrupt committed records fail startup. Uncommitted temporary files are ignored. + +Recovery never resumes, overwrites, or deletes journal history automatically. An explicit administrator can call `reviewRecovery(id)` to inspect counts and a SHA-256 digest of current contents across the union of earlier confirmed receipts and the pending uncertain write mask. `abandonRecovery(id, expectedDigest)` rereads that same mask and refuses a stale digest. It leaves all world blocks untouched, marks the operation `FAILED`, permanently disables undo for that abandoned operation, and invalidates previous ownership on the affected positions. The invalidation revision is persisted and survives restart. This action abandons uncertain history; it is not a rollback or proof that previous writes happened. + +The adapter must expose review and abandonment exclusively through an explicit administrative interface, outside agent MCP tools. Call `flushOperation` after abandonment; the global recovery latch clears only after that exact state is durably saved and every other unresolved operation has also been handled. A crash before that flush retains recovery on restart. A failed flush retains the affected mask for another review. A newer cancellation or recovery decision cannot be acknowledged by an older in-flight flush. Recovery review reads at most the configured plan limit synchronously, so the Paper prototype's 4096-block cap also bounds this administrative operation. + +Recovery uses `ContextGuard.checkRecovery(plan)`. The adapter should retain caller authorization, project/world/epoch identity and region/dependency bounds, while excluding restrictions that apply only to block writes, such as paused writing or protected parts. This lets an administrator abandon uncertain history without reopening protected world editing. The interface default delegates to `check(plan)`, so existing adapters retain their complete guard until they explicitly implement this separation. + +A terminal operation reports states observed at runtime; Minecraft's chunk saving is not transactional with this journal, so those observations do not prove disk persistence of the world after a crash. Undo and subsequent plans always reread live state. Fully automatic reconciliation and restart auditing of completed chunk writes remain future work. + +JSON snapshots rewrite the accumulated receipt list after each slice. This prioritizes inspectability and correctness for the initial small-plan prototype; it is not the final storage design or a claim of 100,000-block throughput. Prepare is synchronous and bounded by `maxChanges`; the Paper prototype should cap it conservatively until chunked snapshot preparation exists. The nanosecond budget is a soft limit checked between blocks, not a preemptive deadline for a slow server API call or dependency precheck. + +## Geometry contract + +The compiler accepts `{ "version": 1, "operations": [...] }`. Coordinates are integer world coordinates. Later operations overwrite earlier positions in the result; the compiler itself never reads or writes the world. + +- `box`: inclusive `min`/`max`, `block`, optional `hollow`. Hollow keeps all six boundary faces. +- `line`: `from`/`to`, `block`. Both endpoints are included; interpolation makes a deterministic 26-connected voxel line. +- `cylinder`: bottom `center`, integer nonnegative `radius`, positive `height`, `block`, optional `hollow`. Hollow retains the side wall and has open top/bottom. +- `repeat`: `count` (1–1024), integer `offset`, nested `operations`. Iteration zero uses zero offset. + +The compiler enforces unique block count, scan budget, nesting depth 8, expanded operation count 4096, checked coordinate arithmetic, and strict fields. Overlapping repeated shapes still consume scan budget. Arbitrary scripts, expressions, transforms, palettes, `.schem`, arch generation, and replacement masks are not implemented here yet. + +## Verification + +Run `mvn -f world-core/pom.xml test` from the repository root. Tests cover mutation during journal IO and between slices, read dependencies, partial cancellation, ownership of skipped blocks, changed permissions/policies, idempotency through a real filesystem restart, undo conflicts and later writers, disk failures, corruption, setter exceptions after mutation, nested world callbacks, final verification, geometry limits, and a stalled journal that must not block status or cancellation. + +Memory-world tests prove the core's state machine and journal boundaries. They do not prove Minecraft threading, chunk persistence, physics behavior, or server performance; those require the real Paper integration tests. diff --git a/world-core/pom.xml b/world-core/pom.xml new file mode 100644 index 0000000..dcf07f1 --- /dev/null +++ b/world-core/pom.xml @@ -0,0 +1,11 @@ + + + 4.0.0 + io.github.minecraftbuilderminecraft-builder-mcp0.1.0-SNAPSHOT + world-core + 17 + + com.google.code.gsongson2.11.0 + org.junit.jupiterjunit-jupiter5.11.4test + + diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/BlockPolicy.java b/world-core/src/main/java/io/github/minecraftbuilder/core/BlockPolicy.java new file mode 100644 index 0000000..98edde0 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/BlockPolicy.java @@ -0,0 +1,6 @@ +package io.github.minecraftbuilder.core; + +@FunctionalInterface +public interface BlockPolicy { + boolean supports(String canonicalState); +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/BlockPos.java b/world-core/src/main/java/io/github/minecraftbuilder/core/BlockPos.java new file mode 100644 index 0000000..1879084 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/BlockPos.java @@ -0,0 +1,14 @@ +package io.github.minecraftbuilder.core; + +public record BlockPos(int x, int y, int z) implements Comparable { + public BlockPos add(BlockPos offset) { + return new BlockPos(Math.addExact(x, offset.x), Math.addExact(y, offset.y), Math.addExact(z, offset.z)); + } + + @Override public int compareTo(BlockPos other) { + int cmp = Integer.compare(y, other.y); + if (cmp == 0) cmp = Integer.compare(z, other.z); + if (cmp == 0) cmp = Integer.compare(x, other.x); + return cmp; + } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/Change.java b/world-core/src/main/java/io/github/minecraftbuilder/core/Change.java new file mode 100644 index 0000000..2a04b72 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/Change.java @@ -0,0 +1,7 @@ +package io.github.minecraftbuilder.core; + +import java.util.Objects; + +public record Change(BlockPos pos, String expected, String desired) { + public Change { Objects.requireNonNull(pos); Objects.requireNonNull(expected); Objects.requireNonNull(desired); } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/Conflict.java b/world-core/src/main/java/io/github/minecraftbuilder/core/Conflict.java new file mode 100644 index 0000000..83a0d06 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/Conflict.java @@ -0,0 +1,3 @@ +package io.github.minecraftbuilder.core; + +public record Conflict(BlockPos pos, String expected, String current, String desired, String reason) { } diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/ContextGuard.java b/world-core/src/main/java/io/github/minecraftbuilder/core/ContextGuard.java new file mode 100644 index 0000000..0bcf6aa --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/ContextGuard.java @@ -0,0 +1,15 @@ +package io.github.minecraftbuilder.core; + +/** Throw to stop on revoked permission, changed world epoch, unloaded chunks, or changed region. */ +@FunctionalInterface +public interface ContextGuard { + void check(Plan plan); + + /** + * Validate context for administrative inspection/abandonment, which never writes world blocks. + * Adapters may omit write-only restrictions (paused writes, protected parts), while retaining + * project/world/epoch identity, region bounds, and caller authorization. The conservative default + * preserves the complete write guard for existing adapters until they explicitly separate it. + */ + default void checkRecovery(Plan plan) { check(plan); } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/EditEngine.java b/world-core/src/main/java/io/github/minecraftbuilder/core/EditEngine.java new file mode 100644 index 0000000..5bbad9a --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/EditEngine.java @@ -0,0 +1,642 @@ +package io.github.minecraftbuilder.core; + +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.ArrayDeque; +import java.util.Comparator; +import java.util.Deque; +import java.util.HashMap; +import java.util.HashSet; +import java.util.HexFormat; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.Set; +import java.util.TreeMap; +import java.util.UUID; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Bounded optimistic editor for one world. The adapter serializes world-thread calls and allows + * only one IO task at a time. persistPlan, persistIntent, and flushOperation run on an IO executor. + * No world writes occur before a durable intent, nor before the post-IO live recheck. + */ +public final class EditEngine { + private final WorldAccess world; + private final BlockPolicy policy; + private final ContextGuard guard; + private final Journal journal; + private final Limits limits; + private final Map plans = new HashMap<>(); + private final Set durablePlans = new HashSet<>(); + private final Map operations = new ConcurrentHashMap<>(); + private final Set pendingOperationIds = new LinkedHashSet<>(); + private final Set recoveryOperationIds = new HashSet<>(); + private final Deque recentOperationIds = new ArrayDeque<>(); + private final Map idempotency = new HashMap<>(); + private final Map lastWriters = new HashMap<>(); + private long revision; + private boolean recoveryRequired; + + private record Writer(String operationId, long revision) { } + private static final class Operation { + final String id; + final Plan plan; + final String key; + OperationStatus state = OperationStatus.QUEUED; + int cursor, skipped, preflightCursor, finalAuditCursor, nextSequence; + final List receipts = new ArrayList<>(); + final List conflicts = new ArrayList<>(); + final Map written = new HashMap<>(); + SliceIntent pending; + boolean intentDurable; + boolean needsFlush = true; + long mutationVersion; + volatile boolean cancelled; + String message = "Awaiting bounded preflight"; + long abandonedRevision; + List abandonedPositions = List.of(); + Operation(String id, Plan plan, String key) { this.id = id; this.plan = plan; this.key = key; } + OperationSnapshot snapshot() { + return new OperationSnapshot(id, plan.id(), key, state, cursor, skipped, preflightCursor, + nextSequence, receipts, pending, conflicts, message, cancelled, abandonedRevision, abandonedPositions); + } + OperationView view() { + return new OperationView(id, plan.id(), plan.projectId(), state, plan.changes().size(), cursor, + receipts.size(), skipped, preflightCursor, List.copyOf(conflicts), message, needsFlush, cancelled); + } + } + + public EditEngine(WorldAccess world, BlockPolicy policy, ContextGuard guard, Journal journal, Limits limits) + throws IOException { + this.world = Objects.requireNonNull(world); this.policy = Objects.requireNonNull(policy); + this.guard = Objects.requireNonNull(guard); this.journal = Objects.requireNonNull(journal); + this.limits = Objects.requireNonNull(limits); + for (Plan plan : journal.loadPlans()) { + validatePlanShape(plan); + if (plans.put(plan.id(), plan) != null) throw new IOException("Duplicate plan ID"); + durablePlans.add(plan.id()); + } + // Plan capture time approximates historical creation order; operation IDs break ties stably. + List savedOperations = new ArrayList<>(journal.loadOperations()); + savedOperations.sort(Comparator.comparingLong(saved -> { + Plan plan = plans.get(saved.planId()); return plan == null ? Long.MIN_VALUE : plan.createdAtMillis(); + }).thenComparing(OperationSnapshot::id)); + for (OperationSnapshot saved : savedOperations) restore(saved); + } + + private void restore(OperationSnapshot saved) throws IOException { + Plan plan = plans.get(saved.planId()); + if (plan == null) throw new IOException("Operation refers to a missing durable plan"); + if (saved.cursor() < 0 || saved.cursor() > plan.changes().size() || saved.skipped() < 0 + || saved.preflightCursor() < 0 || saved.preflightCursor() > plan.changes().size() + || saved.receipts().size() + saved.skipped() != saved.cursor() || saved.status() == null) + throw new IOException("Invalid operation progress"); + Operation op = new Operation(saved.id(), plan, saved.idempotencyKey()); + op.state = saved.status(); op.cursor = saved.cursor(); op.skipped = saved.skipped(); + op.preflightCursor = saved.preflightCursor(); op.nextSequence = saved.nextSequence(); + op.receipts.addAll(saved.receipts()); op.conflicts.addAll(saved.conflicts()); + op.pending = saved.pending(); op.cancelled = saved.cancellationRequested(); op.message = saved.message(); + op.abandonedRevision = saved.abandonedRevision(); op.abandonedPositions = saved.abandonedPositions(); + if (op.abandonedRevision < 0 || op.abandonedPositions.size() > limits.maxChanges() + || (op.abandonedRevision == 0 && !op.abandonedPositions.isEmpty()) + || (op.abandonedRevision > 0 && (op.pending != null + || (op.state != OperationStatus.FAILED && op.state != OperationStatus.RECOVERY_REQUIRED)))) + throw new IOException("Invalid abandonment record"); + op.needsFlush = false; + for (Receipt receipt : saved.receipts()) { + if (!plan.region().contains(receipt.change().pos()) || receipt.revision() < 1) + throw new IOException("Invalid write receipt"); + op.written.put(receipt.change().pos(), receipt.change().desired()); + Writer previous = lastWriters.get(receipt.change().pos()); + if (previous == null || previous.revision() < receipt.revision()) + lastWriters.put(receipt.change().pos(), new Writer(op.id, receipt.revision())); + revision = Math.max(revision, receipt.revision()); + } + if (op.abandonedRevision > 0) { + Set possible = new HashSet<>(); + for (Change change : op.plan.changes()) possible.add(change.pos()); + for (BlockPos position : op.abandonedPositions) { + if (!possible.contains(position)) throw new IOException("Abandoned position is outside the recorded plan"); + Writer previous = lastWriters.get(position); + if (previous == null || previous.revision() < op.abandonedRevision) + lastWriters.put(position, new Writer("abandoned:" + op.id, op.abandonedRevision)); + } + revision = Math.max(revision, op.abandonedRevision); + } + if (!op.state.terminal() || op.pending != null || op.state == OperationStatus.RECOVERY_REQUIRED) { + op.state = OperationStatus.RECOVERY_REQUIRED; + op.message = "Interrupted operation: inspect before/after/current; automatic replay is disabled"; + op.needsFlush = true; recoveryRequired = true; recoveryOperationIds.add(op.id); + } + if (operations.put(op.id, op) != null || idempotency.put(key(plan.projectId(), op.key), op.id) != null) + throw new IOException("Duplicate operation or idempotency key"); + recentOperationIds.addFirst(op.id); + updatePending(op); + } + + /** Captures a limited live snapshot; perform on the world thread. No disk writes or world mutations. */ + public synchronized Plan prepare(String projectId, String worldEpoch, Region region, + Map desired, Set dependencyPositions) { + requireText(projectId, "projectId"); requireText(worldEpoch, "worldEpoch"); + Objects.requireNonNull(region); Objects.requireNonNull(desired); Objects.requireNonNull(dependencyPositions); + if (desired.size() > limits.maxChanges()) throw new IllegalArgumentException("Plan exceeds block limit"); + if (dependencyPositions.size() > limits.maxReadDependencies()) + throw new IllegalArgumentException("Read dependencies exceed limit"); + long now = System.currentTimeMillis(); + String id = UUID.randomUUID().toString(); + Plan context = new Plan(id, projectId, worldEpoch, region, List.of(), List.of(), now, + Math.addExact(now, limits.planTtlMillis()), null); + guard.check(context); + // Validate the whole request before any live reads (and before expensive snapshot work). + for (var entry : desired.entrySet()) { requireInside(region, entry.getKey()); requireSupported(entry.getValue()); } + for (BlockPos position : dependencyPositions) requireInside(region, position); + List changes = new ArrayList<>(); + Map captured = new HashMap<>(); + for (var entry : new TreeMap<>(desired).entrySet()) { + String before = read(entry.getKey()); + requireSupported(before); captured.put(entry.getKey(), before); + changes.add(new Change(entry.getKey(), before, entry.getValue())); + } + List dependencies = new ArrayList<>(); + for (BlockPos position : dependencyPositions.stream().sorted().toList()) { + String before = captured.containsKey(position) ? captured.get(position) : read(position); + requireSupported(before); dependencies.add(new Plan.Dependency(position, before)); + } + Plan plan = new Plan(id, projectId, worldEpoch, region, changes, dependencies, now, + context.expiresAtMillis(), null); + plans.put(id, plan); + return plan; + } + + /** IO executor only. A plan cannot start until this succeeds. */ + public void persistPlan(String planId) throws IOException { + Plan plan; + synchronized (this) { plan = plan(planId); } + journal.savePlan(plan); + synchronized (this) { durablePlans.add(planId); } + } + + /** Creates a memory operation. Persist it with flushOperation before returning a durable operation ID. */ + public synchronized OperationView start(String planId, String idempotencyKey) { + Plan plan = plan(planId); + requireText(idempotencyKey, "idempotencyKey"); + String existingId = idempotency.get(key(plan.projectId(), idempotencyKey)); + if (existingId != null) { + Operation existing = operation(existingId); + if (!existing.plan.id().equals(planId)) throw new IllegalArgumentException("Idempotency key belongs to another plan"); + return existing.view(); + } + if (recoveryRequired) throw new IllegalStateException("recovery_required: unresolved journal prevents new writes"); + if (!durablePlans.contains(planId)) throw new IllegalStateException("Plan is not durable; persistPlan first"); + if (System.currentTimeMillis() > plan.expiresAtMillis()) throw new IllegalStateException("Plan expired; prepare again"); + guard.check(plan); + if (!pendingOperationIds.isEmpty()) + throw new IllegalStateException("busy: one active or unflushed operation is allowed per world engine"); + Operation op = new Operation(UUID.randomUUID().toString(), plan, idempotencyKey); + if (plan.changes().isEmpty()) { op.state = OperationStatus.APPLIED; op.message = "No changes"; } + operations.put(op.id, op); idempotency.put(key(plan.projectId(), idempotencyKey), op.id); + recentOperationIds.addFirst(op.id); updatePending(op); + return op.view(); + } + + /** + * Bounded world-thread preflight, then stage a slice. Empty means poll again if status is active. + * If status.needsFlush, flush off-thread before polling again. No world writes occur here. + */ + public synchronized Optional stageSlice(String operationId) { + Operation op = operation(operationId); + if (op.state.terminal()) return Optional.empty(); + if (op.needsFlush) throw new IllegalStateException("flushOperation must complete before the next slice"); + if (op.pending != null) throw new IllegalStateException("A slice is already staged"); + try { return stageInternal(op); } + catch (RuntimeException e) { + finish(op, OperationStatus.FAILED, "Cannot inspect world: " + e.getMessage()); + return Optional.empty(); + } finally { op.mutationVersion++; } + } + + private Optional stageInternal(Operation op) { + if (!checkContext(op) || !checkDependencies(op)) return Optional.empty(); + long started = System.nanoTime(); + if (op.preflightCursor < op.plan.changes().size()) { + int checked = 0; + while (op.preflightCursor < op.plan.changes().size() && checked < limits.maxBlocksPerSlice()) { + Change change = op.plan.changes().get(op.preflightCursor); + if (!checkChange(op, change, true)) return Optional.empty(); + op.preflightCursor++; checked++; + if (System.nanoTime() - started >= limits.sliceNanos()) break; + } + op.message = "Preflight " + op.preflightCursor + "/" + op.plan.changes().size(); + return Optional.empty(); + } + if (op.cursor == op.plan.changes().size()) { + int checked = 0; + while (op.finalAuditCursor < op.plan.changes().size() && checked < limits.maxBlocksPerSlice()) { + Change change = op.plan.changes().get(op.finalAuditCursor); + String current = read(change.pos()); + if (!current.equals(change.desired())) { + conflict(op, change, current, "Final verification differs from desired state"); + return Optional.empty(); + } + op.finalAuditCursor++; checked++; + if (System.nanoTime() - started >= limits.sliceNanos()) break; + } + if (op.finalAuditCursor == op.plan.changes().size()) + finish(op, OperationStatus.APPLIED, "All requested states verified; snapshot is not an atomic world transaction"); + return Optional.empty(); + } + List slice = new ArrayList<>(); + for (int index = op.cursor; index < op.plan.changes().size() && slice.size() < limits.maxBlocksPerSlice(); index++) { + Change change = op.plan.changes().get(index); + if (!checkChange(op, change, true)) return Optional.empty(); + // The journal records the actual state observed now, including no-op desired states. + slice.add(new Change(change.pos(), read(change.pos()), change.desired())); + if (System.nanoTime() - started >= limits.sliceNanos()) break; + } + op.pending = new SliceIntent(op.id, op.nextSequence++, slice); + op.state = OperationStatus.APPLYING; op.message = "Intent staged; no blocks written"; + op.intentDurable = false; + return Optional.of(op.pending); + } + + /** IO executor only. Failure stops the engine; a partially successful fsync cannot be guessed. */ + public void persistIntent(SliceIntent intent) throws IOException { + Operation op; + OperationSnapshot snapshot; + synchronized (this) { + op = requireIntent(intent); + if (op.intentDurable) return; + snapshot = op.snapshot(); + } + try { + journal.saveOperation(snapshot); + synchronized (this) { + if (op.pending != intent) throw new IllegalStateException("Slice changed during persistence"); + op.intentDurable = true; + } + } catch (IOException e) { + synchronized (this) { recovery(op, "Cannot persist write intent: " + e.getMessage()); } + throw e; + } + } + + /** World thread only: revalidate after IO, write a bounded slice, verify actual writes. */ + public synchronized OperationView commitSlice(SliceIntent intent) { + Operation op = requireIntent(intent); + if (!op.intentDurable) throw new IllegalStateException("Intent is not durable"); + if (op.state.terminal()) return op.view(); + try { return commitInternal(op, intent); } + catch (RuntimeException e) { + recovery(op, "Slice verification failed; retained intent requires reconciliation: " + e.getMessage()); + return op.view(); + } finally { op.mutationVersion++; } + } + + private OperationView commitInternal(Operation op, SliceIntent intent) { + if (!checkContext(op) || !checkDependencies(op)) { discardIntent(op); return op.view(); } + for (Change change : intent.changes()) { + // Strict equality to the journaled before value closes the IO race, including no-op entries. + String current = read(change.pos()); + if (!checkUndoWriter(op, change, current)) { discardIntent(op); return op.view(); } + if (!current.equals(change.expected()) || !policy.supports(current) || !policy.supports(change.desired())) { + conflict(op, change, current, "Changed while intent was persisted"); discardIntent(op); return op.view(); + } + } + long started = System.nanoTime(); + for (Change change : intent.changes()) { + if (op.cancelled) { finish(op, OperationStatus.CANCELLED, "Stopped between blocks; completed writes remain"); break; } + String current = read(change.pos()); + if (!checkUndoWriter(op, change, current)) break; + if (!current.equals(change.expected())) { + conflict(op, change, current, "Changed during slice (possible nested world callback)"); break; + } + if (current.equals(change.desired())) { op.skipped++; op.cursor++; } + else { + try { + world.setBlock(change.pos(), change.desired()); + String actual = read(change.pos()); + if (!actual.equals(change.desired())) { + recovery(op, "Write result is uncertain at " + change.pos()); break; + } + recordWrite(op, change); + } catch (RuntimeException e) { + // A setter can fail after mutation. Observe and retain a confirmed receipt when possible. + try { + String actual = read(change.pos()); + if (actual.equals(change.desired())) recordWrite(op, change); + else if (!actual.equals(change.expected())) { recovery(op, "Setter failed with an unexpected world state"); break; } + finish(op, OperationStatus.FAILED, "World write failed: " + e.getMessage()); + } catch (RuntimeException readFailure) { recovery(op, "World write and verification failed"); } + break; + } + } + if (System.nanoTime() - started >= limits.sliceNanos()) break; + } + if (op.state != OperationStatus.RECOVERY_REQUIRED) discardIntent(op); + op.needsFlush = true; + if (!op.state.terminal()) op.message = "Slice written; awaiting durable receipt"; + return op.view(); + } + + /** IO executor only; the next slice is forbidden until receipts/status have been flushed. */ + public void flushOperation(String operationId) throws IOException { + Operation op; + OperationSnapshot snapshot; + long snapshotVersion; + synchronized (this) { + op = operation(operationId); + if (op.pending != null && !op.state.terminal()) + throw new IllegalStateException("Use persistIntent for a staged slice"); + snapshot = op.snapshot(); + snapshotVersion = op.mutationVersion; + } + try { + journal.saveOperation(snapshot); + synchronized (this) { + // Never publish an older flush as the durable acknowledgement of newer state. + // Normally only cancellation may interleave; explicit recovery also advances this version. + op.needsFlush = op.mutationVersion != snapshotVersion || op.cancelled != snapshot.cancellationRequested(); + updatePending(op); + if (op.abandonedRevision > 0 && op.state == OperationStatus.FAILED && !op.needsFlush) { + recoveryOperationIds.remove(op.id); + recoveryRequired = !recoveryOperationIds.isEmpty(); + } + } + } catch (IOException e) { + synchronized (this) { recovery(op, "Cannot persist operation state: " + e.getMessage()); } + throw e; + } + } + + /** Cancellation does not roll back confirmed blocks. The next stage/commit observes this flag. */ + public OperationView cancel(String operationId) { + Operation op = operation(operationId); + boolean wasCancelled = op.cancelled; + op.cancelled = true; + synchronized (this) { if (!wasCancelled) op.mutationVersion++; return op.view(); } + } + + public synchronized OperationView status(String operationId) { return operation(operationId).view(); } + /** Internal adapter use only: potentially large; do not send the full receipt list to the model. */ + public synchronized List receipts(String operationId) { return List.copyOf(operation(operationId).receipts); } + public synchronized List operations() { + return operations.values().stream().map(Operation::view).sorted(java.util.Comparator.comparing(OperationView::id)).toList(); + } + /** Scheduler index: only active operations or terminal states still requiring a durable flush. */ + public synchronized List pendingOperations() { + return pendingOperationIds.stream().map(id -> operation(id).view()).toList(); + } + /** Bounded context snapshot; most recently created operations first, no receipt copies. */ + public synchronized List recentOperations(int limit) { + if (limit < 0 || limit > 100) throw new IllegalArgumentException("Recent operation limit must be 0..100"); + List result = new ArrayList<>(Math.min(limit, recentOperationIds.size())); + for (String id : recentOperationIds) { + if (result.size() == limit) break; + result.add(operation(id).view()); + } + return List.copyOf(result); + } + public int operationCount() { return operations.size(); } + + /** + * Record an observed external edit on the server thread, even when its value later returns to the + * same state (ABA). This invalidates only known ownership; it does not make journal IO in an event. + * The notification is ephemeral and must not be emitted for this engine's own writes. + */ + public synchronized void recordExternal(BlockPos position) { + Objects.requireNonNull(position); + // Unknown/unowned world positions need no history allocation. + if (!lastWriters.containsKey(position)) return; + revision = Math.incrementExact(revision); + lastWriters.put(position, new Writer("external", revision)); + } + public synchronized Plan plan(String planId) { + Plan result = plans.get(planId); + if (result == null) throw new IllegalArgumentException("Unknown plan"); + return result; + } + + /** Prepare the inverse of confirmed writes; known later writers or changed values reject the whole undo. */ + public synchronized Plan prepareUndo(String operationId) { + Operation source = operation(operationId); + if (!source.state.terminal() || source.state == OperationStatus.RECOVERY_REQUIRED || source.needsFlush + || source.abandonedRevision > 0) + throw new IllegalStateException("Undo requires a terminal, durable, reconciled operation"); + guard.check(source.plan); + Map desired = new LinkedHashMap<>(); + for (Receipt receipt : source.receipts) { + Change change = receipt.change(); + Writer writer = lastWriters.get(change.pos()); + if (writer == null || !writer.operationId().equals(source.id)) + throw new IllegalStateException("Undo conflict: known later operation at " + change.pos()); + String actual = read(change.pos()); + if (!actual.equals(change.desired())) throw new IllegalStateException("Undo conflict: external change at " + change.pos()); + desired.put(change.pos(), change.expected()); + } + Plan base = prepare(source.plan.projectId(), source.plan.worldEpoch(), source.plan.region(), desired, Set.of()); + Plan undo = new Plan(base.id(), base.projectId(), base.worldEpoch(), base.region(), base.changes(), + base.dependencies(), base.createdAtMillis(), base.expiresAtMillis(), source.id); + plans.put(undo.id(), undo); + return undo; + } + + /** Read-only bounded inspection of uncertain intent entries; recovery never auto-replays them. */ + public synchronized List inspectRecovery(String operationId, int offset, int limit) { + Operation op = operation(operationId); + if (op.state != OperationStatus.RECOVERY_REQUIRED) throw new IllegalStateException("Operation does not require recovery"); + guard.checkRecovery(op.plan); + if (offset < 0 || limit < 1 || limit > limits.maxBlocksPerSlice()) throw new IllegalArgumentException("Invalid recovery page"); + List candidates = recoveryChanges(op); + List result = new ArrayList<>(); + for (int i = offset; i < candidates.size() && result.size() < limit; i++) { + Change change = candidates.get(i); + String actual = read(change.pos()); + String reason = actual.equals(change.expected()) ? "matches_before" : actual.equals(change.desired()) ? "matches_after" : "foreign_state"; + result.add(new Conflict(change.pos(), change.expected(), actual, change.desired(), reason)); + } + return List.copyOf(result); + } + + /** + * Server thread, explicit administrator use only. Reads at most maxChanges affected positions. + * The adapter should expose this outside model-accessible tools. No ownership is inferred. + */ + public synchronized RecoveryReview reviewRecovery(String operationId) { + Operation op = operation(operationId); + if (op.state != OperationStatus.RECOVERY_REQUIRED) throw new IllegalStateException("Operation does not require recovery"); + guard.checkRecovery(op.plan); + return recoveryReview(op); + } + + /** + * Explicitly abandon uncertain history, leaving every world block untouched. The current-state + * digest must match a fresh review. Flush off-thread before any new editing can be enabled. + */ + public synchronized OperationView abandonRecovery(String operationId, String expectedDigest) { + Operation op = operation(operationId); + if (op.state != OperationStatus.RECOVERY_REQUIRED) throw new IllegalStateException("Operation does not require recovery"); + if (expectedDigest == null || !expectedDigest.matches("[0-9a-f]{64}")) + throw new IllegalArgumentException("A SHA-256 digest from reviewRecovery is required"); + guard.checkRecovery(op.plan); + RecoveryReview current = recoveryReview(op); + if (!MessageDigest.isEqual(current.currentDigest().getBytes(StandardCharsets.US_ASCII), + expectedDigest.getBytes(StandardCharsets.US_ASCII))) + throw new IllegalStateException("Recovery mask changed since review; inspect and review again"); + List abandoned = recoveryChanges(op).stream().map(Change::pos).toList(); + revision = Math.incrementExact(revision); + op.abandonedRevision = revision; op.abandonedPositions = abandoned; + for (BlockPos position : abandoned) + lastWriters.put(position, new Writer("abandoned:" + op.id, revision)); + discardIntent(op); + finish(op, OperationStatus.FAILED, "Administrator abandoned uncertain history; world unchanged; this operation cannot be undone"); + // Deliberately keep the recovery latch until the exact abandonment state has been persisted. + recoveryOperationIds.add(op.id); recoveryRequired = true; + return op.view(); + } + + private RecoveryReview recoveryReview(Operation op) { + MessageDigest digest; + try { digest = MessageDigest.getInstance("SHA-256"); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + digestString(digest, "minecraft-builder-mcp-recovery-v1"); digestString(digest, op.id); + digestString(digest, op.plan.id()); digestString(digest, op.plan.region().worldId()); + digestString(digest, op.plan.worldEpoch()); + int before = 0, after = 0, foreign = 0; + List changes = recoveryChanges(op); + for (Change change : changes) { + digest.update(ByteBuffer.allocate(12).putInt(change.pos().x()).putInt(change.pos().y()).putInt(change.pos().z()).array()); + String actual = read(change.pos()); + digestString(digest, change.expected()); digestString(digest, change.desired()); digestString(digest, actual); + if (actual.equals(change.expected())) before++; + else if (actual.equals(change.desired())) after++; + else foreign++; + } + return new RecoveryReview(op.id, op.plan.id(), changes.size(), before, after, foreign, + HexFormat.of().formatHex(digest.digest()), System.currentTimeMillis()); + } + + private static void digestString(MessageDigest digest, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + digest.update(ByteBuffer.allocate(4).putInt(bytes.length).array()); digest.update(bytes); + } + + private List recoveryChanges(Operation op) { + Map candidates = new TreeMap<>(); + for (Receipt receipt : op.receipts) candidates.put(receipt.change().pos(), receipt.change()); + if (op.pending != null) for (Change change : op.pending.changes()) { + // A journaled no-op was never owned and does not need write reconciliation. + if (!change.expected().equals(change.desired())) candidates.put(change.pos(), change); + } + if (!op.abandonedPositions.isEmpty()) { + Set abandoned = new HashSet<>(op.abandonedPositions); + for (Change change : op.plan.changes()) + if (abandoned.contains(change.pos())) candidates.putIfAbsent(change.pos(), change); + } + return List.copyOf(candidates.values()); + } + + private void recordWrite(Operation op, Change change) { + long next = Math.incrementExact(revision); revision = next; + op.receipts.add(new Receipt(change, next)); op.written.put(change.pos(), change.desired()); + lastWriters.put(change.pos(), new Writer(op.id, next)); op.cursor++; + } + private boolean checkContext(Operation op) { + if (op.cancelled) { finish(op, OperationStatus.CANCELLED, "Cancelled; confirmed writes remain available for undo"); return false; } + if (System.currentTimeMillis() > op.plan.expiresAtMillis()) { + finish(op, OperationStatus.FAILED, "Plan expired; prepare from fresh state"); return false; + } + try { guard.check(op.plan); } + catch (RuntimeException e) { finish(op, OperationStatus.FAILED, "Context no longer valid: " + e.getMessage()); return false; } + return true; + } + private boolean checkDependencies(Operation op) { + for (Plan.Dependency dependency : op.plan.dependencies()) { + String expected = op.written.getOrDefault(dependency.pos(), dependency.expected()); + String actual = read(dependency.pos()); + if (!expected.equals(actual)) { + conflict(op, new Change(dependency.pos(), expected, expected), actual, "Read dependency changed"); return false; + } + } + return true; + } + private boolean checkChange(Operation op, Change change, boolean allowDesired) { + String actual = read(change.pos()); + if (!policy.supports(actual) || !policy.supports(change.desired()) || !policy.supports(change.expected())) { + conflict(op, change, actual, "Current or planned block state is unsupported by the current policy"); return false; + } + if (!checkUndoWriter(op, change, actual)) return false; + if (!actual.equals(change.expected()) && !(allowDesired && actual.equals(change.desired()))) { + conflict(op, change, actual, "Write set changed since preparation"); return false; + } + return true; + } + private boolean checkUndoWriter(Operation op, Change change, String actual) { + if (op.plan.undoOf() != null) { + Writer writer = lastWriters.get(change.pos()); + if (writer == null || !writer.operationId().equals(op.plan.undoOf())) { + conflict(op, change, actual, "Known later operation prevents undo"); return false; + } + } + return true; + } + private void conflict(Operation op, Change change, String actual, String reason) { + if (op.conflicts.size() < limits.maxConflictDetails()) + op.conflicts.add(new Conflict(change.pos(), change.expected(), actual, change.desired(), reason)); + finish(op, OperationStatus.CONFLICT, reason); + } + private void finish(Operation op, OperationStatus state, String message) { + op.state = state; op.message = message; op.needsFlush = true; + op.mutationVersion++; + updatePending(op); + } + private void recovery(Operation op, String reason) { + recoveryRequired = true; recoveryOperationIds.add(op.id); finish(op, OperationStatus.RECOVERY_REQUIRED, reason); + } + private void discardIntent(Operation op) { op.pending = null; op.intentDurable = false; op.needsFlush = true; } + private void updatePending(Operation op) { + if (!op.state.terminal() || op.needsFlush) pendingOperationIds.add(op.id); + else pendingOperationIds.remove(op.id); + } + private Operation requireIntent(SliceIntent intent) { + Objects.requireNonNull(intent); + Operation op = operation(intent.operationId()); + if (op.pending != intent) throw new IllegalArgumentException("Stale or foreign slice intent"); + return op; + } + private Operation operation(String id) { + Operation op = operations.get(id); + if (op == null) throw new IllegalArgumentException("Unknown operation"); + return op; + } + private String read(BlockPos pos) { return Objects.requireNonNull(world.getBlock(pos), "World returned null state"); } + private void requireSupported(String state) { + if (state == null || !policy.supports(state)) throw new IllegalArgumentException("Unsupported block state: " + state); + } + private static void requireInside(Region region, BlockPos pos) { + if (pos == null || !region.contains(pos)) throw new IllegalArgumentException("Position outside authorized region: " + pos); + } + private static void requireText(String value, String name) { + if (value == null || value.isBlank() || value.length() > 256 || value.chars().anyMatch(c -> c < 32)) + throw new IllegalArgumentException("Invalid " + name); + } + private static String key(String projectId, String idempotencyKey) { return projectId + "\u0000" + idempotencyKey; } + private void validatePlanShape(Plan plan) throws IOException { + if (plan.changes().size() > limits.maxChanges() || plan.dependencies().size() > limits.maxReadDependencies()) + throw new IOException("Stored plan exceeds configured limits"); + Set positions = new HashSet<>(); + for (Change change : plan.changes()) { + if (!plan.region().contains(change.pos()) || !positions.add(change.pos())) + throw new IOException("Stored plan contains invalid/duplicate positions"); + } + for (Plan.Dependency dependency : plan.dependencies()) + if (!plan.region().contains(dependency.pos())) throw new IOException("Stored dependency is outside region"); + } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/Journal.java b/world-core/src/main/java/io/github/minecraftbuilder/core/Journal.java new file mode 100644 index 0000000..e0d080b --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/Journal.java @@ -0,0 +1,12 @@ +package io.github.minecraftbuilder.core; + +import java.io.IOException; +import java.util.List; + +/** save must return only after durable persistence; failures must throw, never be swallowed. */ +public interface Journal { + void savePlan(Plan plan) throws IOException; + void saveOperation(OperationSnapshot operation) throws IOException; + List loadPlans() throws IOException; + List loadOperations() throws IOException; +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/JsonJournal.java b/world-core/src/main/java/io/github/minecraftbuilder/core/JsonJournal.java new file mode 100644 index 0000000..7279072 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/JsonJournal.java @@ -0,0 +1,96 @@ +package io.github.minecraftbuilder.core; + +import com.google.gson.Gson; +import com.google.gson.JsonParseException; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.channels.FileChannel; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.nio.file.StandardOpenOption; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HexFormat; +import java.util.List; +import java.util.UUID; + +/** Atomic, checksummed JSON snapshots. Requires a filesystem supporting atomic rename and directory fsync. */ +public final class JsonJournal implements Journal { + private static final long MAX_RECORD_BYTES = 128L * 1024 * 1024; + private final Gson gson = new Gson(); + private final Path plans; + private final Path operations; + private record Envelope(int format, String kind, String id, String payload, String sha256) { } + + public JsonJournal(Path root) throws IOException { + Path absolute = root.toAbsolutePath(); + Files.createDirectories(absolute); + plans = absolute.resolve("plans"); operations = absolute.resolve("operations"); + Files.createDirectories(plans); Files.createDirectories(operations); + syncDirectory(absolute); + if (absolute.getParent() != null) syncDirectory(absolute.getParent()); + } + @Override public synchronized void savePlan(Plan plan) throws IOException { save(plans, "plan", plan.id(), plan); } + @Override public synchronized void saveOperation(OperationSnapshot operation) throws IOException { + save(operations, "operation", operation.id(), operation); + } + @Override public synchronized List loadPlans() throws IOException { return load(plans, "plan", Plan.class); } + @Override public synchronized List loadOperations() throws IOException { + return load(operations, "operation", OperationSnapshot.class); + } + private void save(Path directory, String kind, String id, Object value) throws IOException { + requireId(id); + String payload = gson.toJson(value); + byte[] bytes = gson.toJson(new Envelope(1, kind, id, payload, checksum(payload))).getBytes(StandardCharsets.UTF_8); + if (bytes.length > MAX_RECORD_BYTES) throw new IOException("Journal record exceeds size limit"); + Path temporary = Files.createTempFile(directory, ".pending-", ".tmp"); + try { + try (FileChannel channel = FileChannel.open(temporary, StandardOpenOption.WRITE)) { + ByteBuffer buffer = ByteBuffer.wrap(bytes); + while (buffer.hasRemaining()) channel.write(buffer); + channel.force(true); + } + Files.move(temporary, directory.resolve(id + ".json"), StandardCopyOption.ATOMIC_MOVE, + StandardCopyOption.REPLACE_EXISTING); + syncDirectory(directory); + } finally { Files.deleteIfExists(temporary); } + } + private List load(Path directory, String kind, Class type) throws IOException { + List result = new ArrayList<>(); + try (var files = Files.list(directory)) { + for (Path file : files.filter(p -> p.getFileName().toString().endsWith(".json")).sorted().toList()) { + if (!Files.isRegularFile(file) || Files.size(file) > MAX_RECORD_BYTES) + throw new IOException("Invalid journal record: " + file.getFileName()); + try { + Envelope envelope = gson.fromJson(Files.readString(file), Envelope.class); + if (envelope == null || envelope.format != 1 || !kind.equals(envelope.kind) + || !file.getFileName().toString().equals(envelope.id + ".json") + || envelope.payload == null || !checksum(envelope.payload).equals(envelope.sha256)) + throw new IOException("Corrupt or unsupported journal: " + file.getFileName()); + requireId(envelope.id); + T value = gson.fromJson(envelope.payload, type); + String recordId = value instanceof Plan p ? p.id() : ((OperationSnapshot) value).id(); + if (!envelope.id.equals(recordId)) throw new IOException("Mismatched journal identity"); + result.add(value); + } catch (JsonParseException | IllegalArgumentException | NullPointerException e) { + throw new IOException("Cannot parse journal: " + file.getFileName(), e); + } + } + } + return List.copyOf(result); + } + private static void requireId(String id) throws IOException { + try { if (!UUID.fromString(id).toString().equals(id)) throw new IllegalArgumentException(); } + catch (IllegalArgumentException | NullPointerException e) { throw new IOException("Invalid journal ID", e); } + } + private static String checksum(String payload) { + try { return HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(payload.getBytes(StandardCharsets.UTF_8))); } + catch (NoSuchAlgorithmException e) { throw new IllegalStateException(e); } + } + private static void syncDirectory(Path directory) throws IOException { + try (FileChannel channel = FileChannel.open(directory, StandardOpenOption.READ)) { channel.force(true); } + } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/Limits.java b/world-core/src/main/java/io/github/minecraftbuilder/core/Limits.java new file mode 100644 index 0000000..1329111 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/Limits.java @@ -0,0 +1,10 @@ +package io.github.minecraftbuilder.core; + +public record Limits(int maxChanges, int maxReadDependencies, int maxBlocksPerSlice, long sliceNanos, + long planTtlMillis, int maxConflictDetails) { + public Limits { + if (maxChanges < 1 || maxReadDependencies < 0 || maxBlocksPerSlice < 1 || sliceNanos < 1 + || planTtlMillis < 1 || maxConflictDetails < 1) throw new IllegalArgumentException("Invalid limits"); + } + public static Limits defaults() { return new Limits(100_000, 512, 512, 5_000_000, 600_000, 32); } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/OperationSnapshot.java b/world-core/src/main/java/io/github/minecraftbuilder/core/OperationSnapshot.java new file mode 100644 index 0000000..189565d --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/OperationSnapshot.java @@ -0,0 +1,16 @@ +package io.github.minecraftbuilder.core; + +import java.util.List; + +/** Journal wire format; clients should use the bounded OperationView. */ +public record OperationSnapshot(String id, String planId, String idempotencyKey, OperationStatus status, + int cursor, int skipped, int preflightCursor, int nextSequence, + List receipts, SliceIntent pending, List conflicts, + String message, boolean cancellationRequested, long abandonedRevision, + List abandonedPositions) { + public OperationSnapshot { + receipts = List.copyOf(receipts); conflicts = List.copyOf(conflicts); + // Older journal version 1 records have no abandonment metadata. + abandonedPositions = abandonedPositions == null ? List.of() : List.copyOf(abandonedPositions); + } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/OperationStatus.java b/world-core/src/main/java/io/github/minecraftbuilder/core/OperationStatus.java new file mode 100644 index 0000000..797120c --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/OperationStatus.java @@ -0,0 +1,6 @@ +package io.github.minecraftbuilder.core; + +public enum OperationStatus { + QUEUED, APPLYING, APPLIED, CONFLICT, CANCELLED, FAILED, RECOVERY_REQUIRED; + public boolean terminal() { return this != QUEUED && this != APPLYING; } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/OperationView.java b/world-core/src/main/java/io/github/minecraftbuilder/core/OperationView.java new file mode 100644 index 0000000..7e1072b --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/OperationView.java @@ -0,0 +1,8 @@ +package io.github.minecraftbuilder.core; + +import java.util.List; + +public record OperationView(String id, String planId, String projectId, OperationStatus status, + int totalChanges, int processed, int written, int skipped, + int preflightChecked, List conflicts, String message, + boolean needsFlush, boolean cancellationRequested) { } diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/Plan.java b/world-core/src/main/java/io/github/minecraftbuilder/core/Plan.java new file mode 100644 index 0000000..bf1e7c7 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/Plan.java @@ -0,0 +1,16 @@ +package io.github.minecraftbuilder.core; + +import java.util.List; +import java.util.Objects; + +/** An immutable snapshot. No-change entries remain read dependencies in the plan. */ +public record Plan(String id, String projectId, String worldEpoch, Region region, List changes, + List dependencies, long createdAtMillis, long expiresAtMillis, String undoOf) { + public Plan { + Objects.requireNonNull(id); Objects.requireNonNull(projectId); Objects.requireNonNull(worldEpoch); + Objects.requireNonNull(region); changes = List.copyOf(changes); dependencies = List.copyOf(dependencies); + } + public record Dependency(BlockPos pos, String expected) { + public Dependency { Objects.requireNonNull(pos); Objects.requireNonNull(expected); } + } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/Receipt.java b/world-core/src/main/java/io/github/minecraftbuilder/core/Receipt.java new file mode 100644 index 0000000..2d9aa5c --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/Receipt.java @@ -0,0 +1,4 @@ +package io.github.minecraftbuilder.core; + +/** Only records a block actually written and observed at its desired state. */ +public record Receipt(Change change, long revision) { } diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/RecipeCompiler.java b/world-core/src/main/java/io/github/minecraftbuilder/core/RecipeCompiler.java new file mode 100644 index 0000000..5eac9c8 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/RecipeCompiler.java @@ -0,0 +1,161 @@ +package io.github.minecraftbuilder.core; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Set; + +/** Small deterministic geometry language. Coordinates are world coordinates; later operations win. */ +public final class RecipeCompiler { + private static final int MAX_DEPTH = 8; + private static final int MAX_EXPANDED_OPERATIONS = 4096; + private RecipeCompiler() { } + + public static Map compile(JsonObject recipe, int maxBlocks) { + if (maxBlocks < 1 || maxBlocks > 2_000_000) throw new IllegalArgumentException("Invalid recipe block limit"); + fields(recipe, Set.of("version", "operations")); + if (integer(recipe, "version") != 1) throw new IllegalArgumentException("Unsupported recipe version"); + Builder builder = new Builder(maxBlocks); + builder.operations(array(recipe, "operations"), new BlockPos(0, 0, 0), 0); + return java.util.Collections.unmodifiableMap(new LinkedHashMap<>(builder.blocks)); + } + + private static final class Builder { + final int maxBlocks; + final long maxVisits; + long visits; + int operationCount; + final Map blocks = new LinkedHashMap<>(); + Builder(int maxBlocks) { this.maxBlocks = maxBlocks; maxVisits = Math.min(2_000_000L, (long) maxBlocks * 16); } + void operations(JsonArray operations, BlockPos offset, int depth) { + if (depth > MAX_DEPTH) throw new IllegalArgumentException("Recipe nesting exceeds limit"); + if (operations.size() > MAX_EXPANDED_OPERATIONS) throw new IllegalArgumentException("Too many operations"); + for (JsonElement element : operations) { + if (++operationCount > MAX_EXPANDED_OPERATIONS) throw new IllegalArgumentException("Expanded operation count exceeds limit"); + if (!element.isJsonObject()) throw new IllegalArgumentException("Operation must be an object"); + JsonObject operation = element.getAsJsonObject(); + String type = string(operation, "type"); + switch (type) { + case "box" -> box(operation, offset); + case "line" -> line(operation, offset); + case "cylinder" -> cylinder(operation, offset); + case "repeat" -> repeat(operation, offset, depth); + default -> throw new IllegalArgumentException("Unsupported operation: " + type); + } + } + } + void box(JsonObject operation, BlockPos offset) { + fields(operation, Set.of("type", "min", "max", "block", "hollow")); + BlockPos min = position(operation, "min").add(offset); + BlockPos max = position(operation, "max").add(offset); + Region region = new Region("recipe", min, max); + charge(region.volume()); + String block = block(operation); boolean hollow = bool(operation, "hollow", false); + for (long y = min.y(); y <= max.y(); y++) for (long z = min.z(); z <= max.z(); z++) for (long x = min.x(); x <= max.x(); x++) { + if (!hollow || x == min.x() || x == max.x() || y == min.y() || y == max.y() || z == min.z() || z == max.z()) + put(new BlockPos((int) x, (int) y, (int) z), block); + } + } + void line(JsonObject operation, BlockPos offset) { + fields(operation, Set.of("type", "from", "to", "block")); + BlockPos from = position(operation, "from").add(offset), to = position(operation, "to").add(offset); + long dx = (long) to.x() - from.x(), dy = (long) to.y() - from.y(), dz = (long) to.z() - from.z(); + long steps = Math.max(Math.max(Math.abs(dx), Math.abs(dy)), Math.abs(dz)); + charge(steps + 1); String block = block(operation); + if (steps == 0) { put(from, block); return; } + for (long step = 0; step <= steps; step++) { + put(new BlockPos(interpolate(from.x(), dx, step, steps), interpolate(from.y(), dy, step, steps), + interpolate(from.z(), dz, step, steps)), block); + } + } + void cylinder(JsonObject operation, BlockPos offset) { + fields(operation, Set.of("type", "center", "radius", "height", "block", "hollow")); + BlockPos center = position(operation, "center").add(offset); + int radius = integer(operation, "radius"), height = integer(operation, "height"); + if (radius < 0 || height < 1) throw new IllegalArgumentException("Cylinder radius must be nonnegative and height positive"); + long diameter = Math.addExact(Math.multiplyExact((long) radius, 2), 1); + charge(Math.multiplyExact(Math.multiplyExact(diameter, diameter), height)); + Math.subtractExact(center.x(), radius); Math.addExact(center.x(), radius); + Math.subtractExact(center.z(), radius); Math.addExact(center.z(), radius); + Math.addExact(center.y(), height - 1); + long outer = (long) radius * radius, inner = (long) (radius - 1) * (radius - 1); + String block = block(operation); boolean hollow = bool(operation, "hollow", false); + for (int y = 0; y < height; y++) for (int z = -radius; z <= radius; z++) for (int x = -radius; x <= radius; x++) { + long distance = (long) x * x + (long) z * z; + if (distance <= outer && (!hollow || radius == 0 || distance > inner)) + put(center.add(new BlockPos(x, y, z)), block); + } + } + void repeat(JsonObject operation, BlockPos offset, int depth) { + fields(operation, Set.of("type", "count", "offset", "operations")); + int count = integer(operation, "count"); + if (count < 1 || count > 1024) throw new IllegalArgumentException("Repeat count must be 1..1024"); + BlockPos step = position(operation, "offset"); JsonArray children = array(operation, "operations"); + for (int i = 0; i < count; i++) { + BlockPos displacement = new BlockPos(Math.multiplyExact(step.x(), i), Math.multiplyExact(step.y(), i), + Math.multiplyExact(step.z(), i)); + operations(children, offset.add(displacement), depth + 1); + } + } + void charge(long amount) { + visits = Math.addExact(visits, amount); + if (visits > maxVisits) throw new IllegalArgumentException("Recipe scan budget exceeded"); + } + void put(BlockPos position, String block) { + if (!blocks.containsKey(position) && blocks.size() >= maxBlocks) throw new IllegalArgumentException("Recipe block limit exceeded"); + blocks.put(position, block); + } + } + + private static int interpolate(int start, long delta, long step, long steps) { + // Integer arithmetic gives deterministic nearest-voxel endpoints without floating-point drift. + long numerator = Math.multiplyExact(delta, step); + long rounded = Math.floorDiv(Math.addExact(Math.multiplyExact(numerator, 2), steps), Math.multiplyExact(steps, 2)); + return Math.toIntExact(Math.addExact(start, rounded)); + } + private static String block(JsonObject object) { + String block = string(object, "block"); + if (block.length() > 512 || !block.matches("minecraft:[a-z0-9_]+(?:\\[[a-z0-9_=,]+\\])?")) + throw new IllegalArgumentException("Expected a Minecraft block state string"); + return block; + } + private static BlockPos position(JsonObject parent, String name) { + JsonElement value = required(parent, name); + if (!value.isJsonObject()) throw new IllegalArgumentException(name + " must be a position object"); + JsonObject object = value.getAsJsonObject(); fields(object, Set.of("x", "y", "z")); + return new BlockPos(integer(object, "x"), integer(object, "y"), integer(object, "z")); + } + private static int integer(JsonObject object, String name) { + JsonElement value = required(object, name); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isNumber() || !value.getAsString().matches("-?(0|[1-9][0-9]*)")) + throw new IllegalArgumentException(name + " must be an integer"); + try { return Integer.parseInt(value.getAsString()); } + catch (NumberFormatException e) { throw new IllegalArgumentException(name + " exceeds 32-bit bounds", e); } + } + private static String string(JsonObject object, String name) { + JsonElement value = required(object, name); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isString()) throw new IllegalArgumentException(name + " must be a string"); + return value.getAsString(); + } + private static boolean bool(JsonObject object, String name, boolean fallback) { + if (!object.has(name)) return fallback; + JsonElement value = object.get(name); + if (!value.isJsonPrimitive() || !value.getAsJsonPrimitive().isBoolean()) throw new IllegalArgumentException(name + " must be a boolean"); + return value.getAsBoolean(); + } + private static JsonArray array(JsonObject object, String name) { + JsonElement value = required(object, name); + if (!value.isJsonArray()) throw new IllegalArgumentException(name + " must be an array"); + return value.getAsJsonArray(); + } + private static JsonElement required(JsonObject object, String name) { + if (object == null || !object.has(name) || object.get(name).isJsonNull()) throw new IllegalArgumentException("Missing field: " + name); + return object.get(name); + } + private static void fields(JsonObject object, Set accepted) { + if (object == null) throw new IllegalArgumentException("Expected an object"); + for (String key : object.keySet()) if (!accepted.contains(key)) throw new IllegalArgumentException("Unknown field: " + key); + } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/RecoveryReview.java b/world-core/src/main/java/io/github/minecraftbuilder/core/RecoveryReview.java new file mode 100644 index 0000000..e0daab4 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/RecoveryReview.java @@ -0,0 +1,5 @@ +package io.github.minecraftbuilder.core; + +/** Bounded summary for an explicit administrator decision; matching content never proves authorship. */ +public record RecoveryReview(String operationId, String planId, int positions, int matchesBefore, + int matchesAfter, int foreignStates, String currentDigest, long sampledAtMillis) { } diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/Region.java b/world-core/src/main/java/io/github/minecraftbuilder/core/Region.java new file mode 100644 index 0000000..4add6ec --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/Region.java @@ -0,0 +1,19 @@ +package io.github.minecraftbuilder.core; + +import java.util.Objects; + +public record Region(String worldId, BlockPos min, BlockPos max) { + public Region { + Objects.requireNonNull(worldId); Objects.requireNonNull(min); Objects.requireNonNull(max); + if (worldId.isBlank() || min.x() > max.x() || min.y() > max.y() || min.z() > max.z()) + throw new IllegalArgumentException("Invalid inclusive region bounds"); + } + public boolean contains(BlockPos pos) { + return pos.x() >= min.x() && pos.x() <= max.x() && pos.y() >= min.y() && pos.y() <= max.y() + && pos.z() >= min.z() && pos.z() <= max.z(); + } + public long volume() { + return Math.multiplyExact(Math.multiplyExact((long) max.x() - min.x() + 1, + (long) max.y() - min.y() + 1), (long) max.z() - min.z() + 1); + } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/SliceIntent.java b/world-core/src/main/java/io/github/minecraftbuilder/core/SliceIntent.java new file mode 100644 index 0000000..f856686 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/SliceIntent.java @@ -0,0 +1,8 @@ +package io.github.minecraftbuilder.core; + +import java.util.List; + +/** Pass this exact staged object through persistIntent then commitSlice; it is not accepted from clients. */ +public record SliceIntent(String operationId, int sequence, List changes) { + public SliceIntent { changes = List.copyOf(changes); } +} diff --git a/world-core/src/main/java/io/github/minecraftbuilder/core/WorldAccess.java b/world-core/src/main/java/io/github/minecraftbuilder/core/WorldAccess.java new file mode 100644 index 0000000..88ca582 --- /dev/null +++ b/world-core/src/main/java/io/github/minecraftbuilder/core/WorldAccess.java @@ -0,0 +1,8 @@ +package io.github.minecraftbuilder.core; + +/** The adapter must call all engine methods that touch this interface on its world thread. */ +public interface WorldAccess { + String getBlock(BlockPos position); + /** Apply canonical state without physics; unsupported side effects must be excluded by BlockPolicy. */ + void setBlock(BlockPos position, String canonicalState); +} diff --git a/world-core/src/test/java/io/github/minecraftbuilder/core/EditEngineTest.java b/world-core/src/test/java/io/github/minecraftbuilder/core/EditEngineTest.java new file mode 100644 index 0000000..b172d42 --- /dev/null +++ b/world-core/src/test/java/io/github/minecraftbuilder/core/EditEngineTest.java @@ -0,0 +1,576 @@ +package io.github.minecraftbuilder.core; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicBoolean; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; +import static org.junit.jupiter.api.Assertions.*; + +class EditEngineTest { + static final String AIR = "minecraft:air", STONE = "minecraft:stone", GOLD = "minecraft:gold_block"; + static final BlockPos A = new BlockPos(0, 0, 0), B = new BlockPos(1, 0, 0), C = new BlockPos(2, 0, 0); + static final Region REGION = new Region("world", new BlockPos(-20, -20, -20), new BlockPos(20, 20, 20)); + static final Limits LIMITS = new Limits(4096, 16, 1, 1_000_000_000, 600_000, 4); + @TempDir Path temporary; + + static class MemoryWorld implements WorldAccess { + final Map blocks = new HashMap<>(); int writes; + Runnable afterWrite; + @Override public String getBlock(BlockPos position) { return blocks.getOrDefault(position, AIR); } + @Override public void setBlock(BlockPos position, String state) { + blocks.put(position, state); writes++; + if (afterWrite != null) afterWrite.run(); + } + } + static class MemoryJournal implements Journal { + final Map plans = new HashMap<>(); + final Map operations = new HashMap<>(); + boolean fail; + @Override public void savePlan(Plan plan) throws IOException { check(); plans.put(plan.id(), plan); } + @Override public void saveOperation(OperationSnapshot op) throws IOException { check(); operations.put(op.id(), op); } + @Override public List loadPlans() { return List.copyOf(plans.values()); } + @Override public List loadOperations() { return List.copyOf(operations.values()); } + void check() throws IOException { if (fail) throw new IOException("Injected disk failure"); } + } + static EditEngine engine(MemoryWorld world, Journal journal) throws IOException { + return new EditEngine(world, state -> Set.of(AIR, STONE, GOLD).contains(state), plan -> {}, journal, LIMITS); + } + static Plan prepare(EditEngine engine, Map desired) throws IOException { + Plan plan = engine.prepare("project", "epoch", REGION, desired, Set.of()); + engine.persistPlan(plan.id()); return plan; + } + static String start(EditEngine engine, Plan plan) throws IOException { + String id = engine.start(plan.id(), "request-" + plan.id()).id(); engine.flushOperation(id); return id; + } + static SliceIntent nextIntent(EditEngine engine, String id) throws IOException { + for (int i = 0; i < 100; i++) { + var intent = engine.stageSlice(id); + if (intent.isPresent()) return intent.get(); + OperationView view = engine.status(id); + if (view.needsFlush()) engine.flushOperation(id); + if (view.status().terminal()) throw new AssertionError("Terminal before intent: " + view); + } + throw new AssertionError("No intent"); + } + static void slice(EditEngine engine, String id) throws IOException { + SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); engine.commitSlice(intent); engine.flushOperation(id); + } + static OperationView finish(EditEngine engine, String id) throws IOException { + for (int i = 0; i < 1000; i++) { + OperationView view = engine.status(id); + if (view.needsFlush()) engine.flushOperation(id); + if (view.status().terminal()) return engine.status(id); + var intent = engine.stageSlice(id); + if (intent.isPresent()) { engine.persistIntent(intent.get()); engine.commitSlice(intent.get()); } + } + throw new AssertionError("Operation failed to terminate"); + } + + @Test void manualChangeAnywhereBeforeApplyStopsEntirePlan() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + Plan plan = prepare(engine, Map.of(A, STONE, B, STONE, C, STONE)); + world.blocks.put(C, GOLD); String id = start(engine, plan); + assertEquals(OperationStatus.CONFLICT, finish(engine, id).status()); + assertEquals(0, world.writes); assertEquals(GOLD, world.getBlock(C)); + } + @Test void ioRaceStopsBeforeAnySliceWrites() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String id = start(engine, prepare(engine, Map.of(A, STONE))); + SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); + world.blocks.put(A, GOLD); OperationView result = engine.commitSlice(intent); + assertEquals(OperationStatus.CONFLICT, result.status()); assertEquals(0, world.writes); + } + @Test void noWriteBeforeDurableIntentAndNoNextSliceBeforeDurableReceipt() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE))); + SliceIntent intent = nextIntent(engine, id); + assertThrows(IllegalStateException.class, () -> engine.commitSlice(intent)); assertEquals(0, world.writes); + engine.persistIntent(intent); engine.commitSlice(intent); + assertThrows(IllegalStateException.class, () -> engine.stageSlice(id)); + assertEquals(1, world.writes); + } + @Test void editBetweenSlicesKeepsConfirmedPartialResult() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE, C, STONE))); + slice(engine, id); world.blocks.put(B, GOLD); + OperationView result = finish(engine, id); + assertEquals(OperationStatus.CONFLICT, result.status()); assertEquals(1, result.written()); + assertEquals(STONE, world.getBlock(A)); assertEquals(GOLD, world.getBlock(B)); assertEquals(AIR, world.getBlock(C)); + } + @Test void externalChangeToDesiredIsSkippedAndNotOwnedByUndo() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + Plan plan = prepare(engine, Map.of(A, STONE, B, STONE)); world.blocks.put(A, STONE); + String id = start(engine, plan); OperationView result = finish(engine, id); + assertEquals(OperationStatus.APPLIED, result.status()); assertEquals(1, result.written()); assertEquals(1, result.skipped()); + Plan undo = engine.prepareUndo(id); engine.persistPlan(undo.id()); finish(engine, start(engine, undo)); + assertEquals(STONE, world.getBlock(A)); assertEquals(AIR, world.getBlock(B)); + } + @Test void readDependencyChangeBetweenSlicesStopsFutureWrites() throws Exception { + MemoryWorld world = new MemoryWorld(); world.blocks.put(C, STONE); + EditEngine engine = engine(world, new MemoryJournal()); + Plan plan = engine.prepare("project", "epoch", REGION, Map.of(A, STONE, B, STONE), Set.of(C)); engine.persistPlan(plan.id()); + String id = start(engine, plan); slice(engine, id); world.blocks.put(C, AIR); + OperationView result = finish(engine, id); + assertEquals(OperationStatus.CONFLICT, result.status()); assertEquals(1, result.written()); + assertEquals(AIR, world.getBlock(B)); assertEquals(C, result.conflicts().get(0).pos()); + } + @Test void dependencyWrittenByThisOperationUsesConfirmedDesiredValue() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + Plan plan = engine.prepare("project", "epoch", REGION, Map.of(A, STONE, B, STONE), Set.of(A)); engine.persistPlan(plan.id()); + assertEquals(OperationStatus.APPLIED, finish(engine, start(engine, plan)).status()); + } + @Test void cancellationAfterOneSliceCanBeUndoneWithoutTouchingRemainingBlocks() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE))); + slice(engine, id); engine.cancel(id); + assertEquals(OperationStatus.CANCELLED, finish(engine, id).status()); + Plan undo = engine.prepareUndo(id); engine.persistPlan(undo.id()); finish(engine, start(engine, undo)); + assertEquals(AIR, world.getBlock(A)); assertEquals(AIR, world.getBlock(B)); assertEquals(2, world.writes); + } + @Test void cancellationWhileIntentIsPersistedWritesNothing() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String id = start(engine, prepare(engine, Map.of(A, STONE))); + SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); engine.cancel(id); + assertEquals(OperationStatus.CANCELLED, engine.commitSlice(intent).status()); assertEquals(0, world.writes); + } + @Test void repeatedApplyUsesSameOperationAcrossRestartAndRejectsDifferentPlan() throws Exception { + MemoryWorld world = new MemoryWorld(); JsonJournal journal = new JsonJournal(temporary); + EditEngine engine = engine(world, journal); Plan plan = prepare(engine, Map.of(A, STONE)); + String id = start(engine, plan); finish(engine, id); + assertEquals(id, engine.start(plan.id(), "request-" + plan.id()).id()); assertEquals(1, world.writes); + EditEngine restarted = engine(world, new JsonJournal(temporary)); + assertEquals(id, restarted.start(plan.id(), "request-" + plan.id()).id()); assertEquals(1, world.writes); + Plan other = prepare(restarted, Map.of(B, STONE)); + assertThrows(IllegalArgumentException.class, () -> restarted.start(other.id(), "request-" + plan.id())); + } + @Test void undoRejectsExternalEditBeforePreparationOrAfterPreparation() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String id = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, id); + world.blocks.put(A, GOLD); assertThrows(IllegalStateException.class, () -> engine.prepareUndo(id)); + world.blocks.put(A, STONE); Plan undo = engine.prepareUndo(id); engine.persistPlan(undo.id()); + world.blocks.put(A, GOLD); + assertEquals(OperationStatus.CONFLICT, finish(engine, start(engine, undo)).status()); assertEquals(GOLD, world.getBlock(A)); + } + @Test void undoRejectsKnownLaterWriterEvenWhenCurrentValueMatchesOriginalAfter() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String first = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, first); + finish(engine, start(engine, prepare(engine, Map.of(A, GOLD)))); + finish(engine, start(engine, prepare(engine, Map.of(A, STONE)))); + assertThrows(IllegalStateException.class, () -> engine.prepareUndo(first)); + } + @Test void unsupportedSourceOrDestinationAndOutOfBoundsAreRejectedBeforeWriting() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + assertThrows(IllegalArgumentException.class, () -> prepare(engine, Map.of(A, "minecraft:chest"))); + world.blocks.put(A, "minecraft:chest"); + assertThrows(IllegalArgumentException.class, () -> prepare(engine, Map.of(A, AIR))); + assertThrows(IllegalArgumentException.class, () -> prepare(engine, Map.of(new BlockPos(21, 0, 0), STONE))); + assertThrows(IllegalArgumentException.class, () -> engine.prepare("project", "epoch", REGION, Map.of(B, STONE), Set.of(new BlockPos(0, 21, 0)))); + assertEquals(0, world.writes); + } + @Test void permissionRevokedDuringIntentIoStopsSlice() throws Exception { + MemoryWorld world = new MemoryWorld(); AtomicBoolean allowed = new AtomicBoolean(true); + EditEngine engine = new EditEngine(world, state -> true, plan -> { + if (!allowed.get()) throw new IllegalStateException("access revoked"); + }, new MemoryJournal(), LIMITS); + String id = start(engine, prepare(engine, Map.of(A, STONE))); + SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); allowed.set(false); + assertEquals(OperationStatus.FAILED, engine.commitSlice(intent).status()); assertEquals(0, world.writes); + } + @Test void failedIntentPersistenceNeverWritesAndBlocksFurtherOperations() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine engine = engine(world, journal); + String id = start(engine, prepare(engine, Map.of(A, STONE))); + SliceIntent intent = nextIntent(engine, id); journal.fail = true; + assertThrows(IOException.class, () -> engine.persistIntent(intent)); + assertEquals(OperationStatus.RECOVERY_REQUIRED, engine.status(id).status()); assertEquals(0, world.writes); + journal.fail = false; Plan other = prepare(engine, Map.of(B, STONE)); + assertThrows(IllegalStateException.class, () -> engine.start(other.id(), "other")); + } + @Test void failedReceiptPersistenceRestartsAsUncertainWithoutReplay() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine engine = engine(world, journal); + String id = start(engine, prepare(engine, Map.of(A, STONE))); + SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); engine.commitSlice(intent); + journal.fail = true; assertThrows(IOException.class, () -> engine.flushOperation(id)); journal.fail = false; + EditEngine restarted = engine(world, journal); + assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.status(id).status()); assertEquals(1, world.writes); + assertEquals("matches_after", restarted.inspectRecovery(id, 0, 1).get(0).reason()); + assertTrue(restarted.stageSlice(id).isEmpty()); assertEquals(1, world.writes); + } + @Test void crashBeforeWriteRecognizesBeforeAndForeignStatesWithoutChangingEither() throws Exception { + MemoryWorld world = new MemoryWorld(); JsonJournal journal = new JsonJournal(temporary); EditEngine engine = engine(world, journal); + String id = start(engine, prepare(engine, Map.of(A, STONE))); + SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); + EditEngine restarted = engine(world, new JsonJournal(temporary)); + assertEquals("matches_before", restarted.inspectRecovery(id, 0, 1).get(0).reason()); + world.blocks.put(A, GOLD); assertEquals("foreign_state", restarted.inspectRecovery(id, 0, 1).get(0).reason()); + assertEquals(0, world.writes); + } + @Test void worldSetterThrowAfterMutationPreservesConfirmedReceiptForUndo() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + world.afterWrite = () -> { throw new IllegalStateException("post-write callback failed"); }; + String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE))); + OperationView result = finish(engine, id); + assertEquals(OperationStatus.FAILED, result.status()); assertEquals(1, result.written()); + world.afterWrite = null; Plan undo = engine.prepareUndo(id); engine.persistPlan(undo.id()); finish(engine, start(engine, undo)); + assertEquals(AIR, world.getBlock(A)); + } + @Test void nestedWorldCallbackCannotOverwriteLaterPositionInSameSlice() throws Exception { + MemoryWorld world = new MemoryWorld(); + EditEngine engine = new EditEngine(world, state -> true, plan -> {}, new MemoryJournal(), + new Limits(10, 10, 10, 1_000_000_000, 600_000, 4)); + world.afterWrite = () -> world.blocks.put(B, GOLD); + String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE))); + OperationView result = finish(engine, id); + assertEquals(OperationStatus.CONFLICT, result.status()); assertEquals(1, result.written()); assertEquals(GOLD, world.getBlock(B)); + } + @Test void finalAuditDetectsChangeToAlreadyWrittenBlock() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE))); + slice(engine, id); slice(engine, id); world.blocks.put(A, GOLD); + assertEquals(OperationStatus.CONFLICT, finish(engine, id).status()); assertEquals(GOLD, world.getBlock(A)); + } + @Test void returnedPlansAndIntentsAreImmutable() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + Map desired = new LinkedHashMap<>(Map.of(A, STONE)); Plan plan = prepare(engine, desired); + desired.put(B, GOLD); assertEquals(1, plan.changes().size()); + assertThrows(UnsupportedOperationException.class, () -> plan.changes().clear()); + SliceIntent intent = nextIntent(engine, start(engine, plan)); + assertThrows(UnsupportedOperationException.class, () -> intent.changes().clear()); + SliceIntent copy = new SliceIntent(intent.operationId(), intent.sequence(), intent.changes()); + assertThrows(IllegalArgumentException.class, () -> engine.persistIntent(copy)); + } + + @Test void stalledJournalDoesNotBlockStatusOrCancelAndDoesNotLoseCancellation() throws Exception { + CountDownLatch saving = new CountDownLatch(1), release = new CountDownLatch(1); + MemoryJournal journal = new MemoryJournal() { + @Override public void saveOperation(OperationSnapshot op) throws IOException { + saving.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Test journal timeout"); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException(e); } + super.saveOperation(op); + } + }; + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, journal); + Plan plan = prepare(engine, Map.of(A, STONE)); String id = engine.start(plan.id(), "request").id(); + CompletableFuture flushing = CompletableFuture.runAsync(() -> { + try { engine.flushOperation(id); } catch (IOException e) { throw new RuntimeException(e); } + }); + assertTrue(saving.await(2, TimeUnit.SECONDS)); + try { + var cancellation = CompletableFuture.supplyAsync(() -> { engine.status(id); return engine.cancel(id); }); + assertTrue(cancellation.get(1, TimeUnit.SECONDS).cancellationRequested()); + } finally { release.countDown(); } + flushing.get(2, TimeUnit.SECONDS); + assertTrue(engine.status(id).needsFlush()); + engine.flushOperation(id); + assertEquals(OperationStatus.CANCELLED, finish(engine, id).status()); assertEquals(0, world.writes); + } + + @Test void storedPlanIsRecheckedAgainstChangedBlockPolicy() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); + Plan plan = prepare(engine(world, journal), Map.of(A, STONE)); + EditEngine restricted = new EditEngine(world, state -> AIR.equals(state), ignored -> {}, journal, LIMITS); + assertEquals(OperationStatus.CONFLICT, finish(restricted, start(restricted, plan)).status()); + assertEquals(0, world.writes); + } + + @Test void pendingIndexRetainsDirtyTerminalStateUntilFlushAndExcludesFinishedHistory() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + Plan first = prepare(engine, Map.of()); + String firstId = engine.start(first.id(), "empty").id(); + assertEquals(OperationStatus.APPLIED, engine.status(firstId).status()); + assertEquals(List.of(firstId), engine.pendingOperations().stream().map(OperationView::id).toList()); + engine.flushOperation(firstId); assertTrue(engine.pendingOperations().isEmpty()); + + String id = start(engine, prepare(engine, Map.of(A, STONE))); + assertEquals(List.of(id), engine.pendingOperations().stream().map(OperationView::id).toList()); + engine.cancel(id); engine.stageSlice(id); + assertEquals(OperationStatus.CANCELLED, engine.pendingOperations().get(0).status()); + engine.flushOperation(id); assertTrue(engine.pendingOperations().isEmpty()); + assertEquals(2, engine.operationCount()); + } + + @Test void recentContextAndSchedulerRemainBoundedWithLargeCompletedHistory() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + List ids = new java.util.ArrayList<>(); + for (int i = 0; i < 150; i++) ids.add(start(engine, prepare(engine, Map.of()))); + assertEquals(150, engine.operationCount()); assertTrue(engine.pendingOperations().isEmpty()); + assertEquals(7, engine.recentOperations(7).size()); + assertEquals(ids.get(149), engine.recentOperations(7).get(0).id()); + assertEquals(ids.get(143), engine.recentOperations(7).get(6).id()); + assertTrue(engine.recentOperations(0).isEmpty()); + assertThrows(IllegalArgumentException.class, () -> engine.recentOperations(101)); + } + + @Test void restartedIndexesIncludeUnflushedRecoveryWithoutAllTerminalHistory() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); + EditEngine engine = engine(world, journal); + start(engine, prepare(engine, Map.of())); + String active = start(engine, prepare(engine, Map.of(A, STONE))); + EditEngine restarted = engine(world, journal); + assertEquals(2, restarted.operationCount()); assertEquals(2, restarted.recentOperations(10).size()); + assertEquals(List.of(active), restarted.pendingOperations().stream().map(OperationView::id).toList()); + assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.pendingOperations().get(0).status()); + restarted.flushOperation(active); assertTrue(restarted.pendingOperations().isEmpty()); + Plan other = prepare(restarted, Map.of(B, STONE)); + assertThrows(IllegalStateException.class, () -> restarted.start(other.id(), "still-blocked")); + } + + @Test void observedExternalAbaRejectsUndoEvenWhenStateReturnsToDesired() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String source = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, source); + world.blocks.put(A, AIR); engine.recordExternal(A); + world.blocks.put(A, STONE); engine.recordExternal(A); + assertThrows(IllegalStateException.class, () -> engine.prepareUndo(source)); + assertEquals(1, world.writes); assertEquals(STONE, world.getBlock(A)); + } + + @Test void observedExternalAbaDuringUndoIntentPersistenceIsRecheckedAtCommit() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String source = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, source); + Plan undo = engine.prepareUndo(source); engine.persistPlan(undo.id()); + String inverse = start(engine, undo); SliceIntent intent = nextIntent(engine, inverse); engine.persistIntent(intent); + world.blocks.put(A, AIR); engine.recordExternal(A); world.blocks.put(A, STONE); engine.recordExternal(A); + assertEquals(OperationStatus.CONFLICT, engine.commitSlice(intent).status()); + assertEquals(1, world.writes); assertEquals(STONE, world.getBlock(A)); + } + + @Test void externalNotificationForUnownedPositionDoesNotAffectUnrelatedUndo() throws Exception { + MemoryWorld world = new MemoryWorld(); EditEngine engine = engine(world, new MemoryJournal()); + String source = start(engine, prepare(engine, Map.of(A, STONE))); finish(engine, source); + engine.recordExternal(B); + Plan undo = engine.prepareUndo(source); engine.persistPlan(undo.id()); + assertEquals(OperationStatus.APPLIED, finish(engine, start(engine, undo)).status()); + assertEquals(AIR, world.getBlock(A)); + } + + @Test void recoveryInspectsEarlierConfirmedSlicesAndUncertainCurrentSliceTogether() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine engine = engine(world, journal); + String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE))); + slice(engine, id); + SliceIntent second = nextIntent(engine, id); engine.persistIntent(second); + EditEngine restarted = engine(world, journal); + assertEquals(A, restarted.inspectRecovery(id, 0, 1).get(0).pos()); + assertEquals("matches_after", restarted.inspectRecovery(id, 0, 1).get(0).reason()); + assertEquals(B, restarted.inspectRecovery(id, 1, 1).get(0).pos()); + assertEquals("matches_before", restarted.inspectRecovery(id, 1, 1).get(0).reason()); + assertEquals(1, world.writes); + } + + @Test void crashInsideSliceRecognizesMixedWorldStatesWithoutAssumingAllWritesHappened() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); + EditEngine engine = new EditEngine(world, state -> true, ignored -> {}, journal, + new Limits(10, 10, 10, 1_000_000_000, 600_000, 4)); + String id = start(engine, prepare(engine, Map.of(A, STONE, B, STONE))); + SliceIntent intent = nextIntent(engine, id); engine.persistIntent(intent); + // Model process death after one write but before any in-memory/durable receipt can be trusted. + world.setBlock(A, STONE); + EditEngine restarted = engine(world, journal); + assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.status(id).status()); + assertEquals("matches_after", restarted.inspectRecovery(id, 0, 1).get(0).reason()); + assertEquals("matches_before", restarted.inspectRecovery(id, 1, 1).get(0).reason()); + assertEquals(0, restarted.status(id).written()); assertEquals(1, world.writes); + } + + @Test void controlCharactersCannotCrossIdempotencyProjectScope() throws Exception { + EditEngine engine = engine(new MemoryWorld(), new MemoryJournal()); + assertThrows(IllegalArgumentException.class, + () -> engine.prepare("project\u0000key", "epoch", REGION, Map.of(), Set.of())); + Plan plan = prepare(engine, Map.of()); + assertThrows(IllegalArgumentException.class, () -> engine.start(plan.id(), "key\u0000suffix")); + } + + @Test void recoveryAbandonRequiresUnchangedReviewedMaskAndDoesNotWriteWorld() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal); + String id = start(initial, prepare(initial, Map.of(A, STONE, B, STONE))); + slice(initial, id); SliceIntent second = nextIntent(initial, id); initial.persistIntent(second); + EditEngine recovered = engine(world, journal); + RecoveryReview review = recovered.reviewRecovery(id); + assertEquals(2, review.positions()); assertEquals(1, review.matchesBefore()); assertEquals(1, review.matchesAfter()); + assertEquals(review.currentDigest(), recovered.reviewRecovery(id).currentDigest()); + world.blocks.put(B, "minecraft:chest"); + assertThrows(IllegalStateException.class, () -> recovered.abandonRecovery(id, review.currentDigest())); + assertEquals(OperationStatus.RECOVERY_REQUIRED, recovered.status(id).status()); + RecoveryReview changed = recovered.reviewRecovery(id); assertEquals(1, changed.foreignStates()); + OperationView abandoned = recovered.abandonRecovery(id, changed.currentDigest()); + assertEquals(OperationStatus.FAILED, abandoned.status()); assertTrue(abandoned.needsFlush()); + assertEquals(1, world.writes); assertEquals("minecraft:chest", world.getBlock(B)); + Plan next = prepare(recovered, Map.of(C, STONE)); + assertThrows(IllegalStateException.class, () -> recovered.start(next.id(), "before-abandonment-flush")); + recovered.flushOperation(id); + assertThrows(IllegalStateException.class, () -> recovered.prepareUndo(id)); + assertEquals(OperationStatus.APPLIED, finish(recovered, start(recovered, next)).status()); + assertEquals("minecraft:chest", world.getBlock(B)); + } + + @Test void crashBeforeAbandonmentFlushRetainsRecoveryLatchAndOriginalMask() throws Exception { + MemoryWorld world = new MemoryWorld(); JsonJournal journal = new JsonJournal(temporary); EditEngine initial = engine(world, journal); + String id = start(initial, prepare(initial, Map.of(A, STONE))); + SliceIntent intent = nextIntent(initial, id); initial.persistIntent(intent); + EditEngine recovered = engine(world, new JsonJournal(temporary)); + recovered.abandonRecovery(id, recovered.reviewRecovery(id).currentDigest()); + EditEngine crashedAgain = engine(world, new JsonJournal(temporary)); + assertEquals(OperationStatus.RECOVERY_REQUIRED, crashedAgain.status(id).status()); + assertEquals(1, crashedAgain.reviewRecovery(id).positions()); + Plan next = prepare(crashedAgain, Map.of(B, STONE)); + assertThrows(IllegalStateException.class, () -> crashedAgain.start(next.id(), "still-unresolved")); + assertEquals(0, world.writes); + } + + @Test void failedAbandonmentFlushKeepsMaskForAnotherExplicitReview() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal); + String id = start(initial, prepare(initial, Map.of(A, STONE, B, STONE))); + slice(initial, id); SliceIntent second = nextIntent(initial, id); initial.persistIntent(second); + EditEngine recovered = engine(world, journal); + recovered.abandonRecovery(id, recovered.reviewRecovery(id).currentDigest()); + journal.fail = true; assertThrows(IOException.class, () -> recovered.flushOperation(id)); + assertEquals(OperationStatus.RECOVERY_REQUIRED, recovered.status(id).status()); + assertEquals(2, recovered.reviewRecovery(id).positions()); + journal.fail = false; + // Even a persisted recovery marker carrying prior abandonment metadata must remain blocked. + recovered.flushOperation(id); + EditEngine restarted = engine(world, journal); + assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.status(id).status()); + assertEquals(2, restarted.reviewRecovery(id).positions()); + restarted.abandonRecovery(id, restarted.reviewRecovery(id).currentDigest()); restarted.flushOperation(id); + assertEquals(OperationStatus.APPLIED, finish(restarted, start(restarted, prepare(restarted, Map.of(C, STONE)))).status()); + } + + @Test void durableAbandonmentInvalidatesEarlierOwnershipAcrossRestartButAllowsNewHistory() throws Exception { + MemoryWorld world = new MemoryWorld(); JsonJournal journal = new JsonJournal(temporary); EditEngine initial = engine(world, journal); + String first = start(initial, prepare(initial, Map.of(A, STONE))); finish(initial, first); + String interrupted = start(initial, prepare(initial, Map.of(A, GOLD))); + SliceIntent intent = nextIntent(initial, interrupted); initial.persistIntent(intent); + EditEngine recovered = engine(world, new JsonJournal(temporary)); + recovered.abandonRecovery(interrupted, recovered.reviewRecovery(interrupted).currentDigest()); recovered.flushOperation(interrupted); + assertThrows(IllegalStateException.class, () -> recovered.prepareUndo(first)); + EditEngine restarted = engine(world, new JsonJournal(temporary)); + assertEquals(OperationStatus.FAILED, restarted.status(interrupted).status()); + assertTrue(restarted.pendingOperations().isEmpty()); + assertThrows(IllegalStateException.class, () -> restarted.prepareUndo(first)); + assertThrows(IllegalStateException.class, () -> restarted.prepareUndo(interrupted)); + String latest = start(restarted, prepare(restarted, Map.of(A, GOLD))); finish(restarted, latest); + EditEngine lastRestart = engine(world, new JsonJournal(temporary)); + Plan undoLatest = lastRestart.prepareUndo(latest); lastRestart.persistPlan(undoLatest.id()); + assertEquals(OperationStatus.APPLIED, finish(lastRestart, start(lastRestart, undoLatest)).status()); + assertEquals(STONE, world.getBlock(A)); + } + + @Test void digestCannotAbandonAnotherOperationWithSameBlockContents() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal firstJournal = new MemoryJournal(), secondJournal = new MemoryJournal(); + EditEngine first = engine(world, firstJournal), second = engine(world, secondJournal); + String firstId = start(first, prepare(first, Map.of(A, STONE))); + String secondId = start(second, prepare(second, Map.of(A, STONE))); + first.persistIntent(nextIntent(first, firstId)); second.persistIntent(nextIntent(second, secondId)); + EditEngine recoveredFirst = engine(world, firstJournal), recoveredSecond = engine(world, secondJournal); + String firstDigest = recoveredFirst.reviewRecovery(firstId).currentDigest(); + assertThrows(IllegalStateException.class, () -> recoveredSecond.abandonRecovery(secondId, firstDigest)); + assertEquals(OperationStatus.RECOVERY_REQUIRED, recoveredSecond.status(secondId).status()); + assertEquals(0, world.writes); + } + + @Test void queuedOperationWithNoIntentCanBeExplicitlyAbandonedWithoutInventingWrites() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal); + String id = start(initial, prepare(initial, Map.of(A, STONE))); + EditEngine recovered = engine(world, journal); + RecoveryReview review = recovered.reviewRecovery(id); assertEquals(0, review.positions()); + recovered.abandonRecovery(id, review.currentDigest()); recovered.flushOperation(id); + assertEquals(0, world.writes); + assertEquals(OperationStatus.APPLIED, finish(recovered, start(recovered, prepare(recovered, Map.of(B, STONE)))).status()); + } + + @Test void oldRecoveryFlushCannotAcknowledgeNewerAbandonment() throws Exception { + AtomicBoolean block = new AtomicBoolean(false); + CountDownLatch saving = new CountDownLatch(1), release = new CountDownLatch(1); + MemoryJournal journal = new MemoryJournal() { + @Override public void saveOperation(OperationSnapshot op) throws IOException { + if (block.get()) { + saving.countDown(); + try { if (!release.await(5, TimeUnit.SECONDS)) throw new IOException("Test journal timeout"); } + catch (InterruptedException e) { Thread.currentThread().interrupt(); throw new IOException(e); } + } + super.saveOperation(op); + } + }; + MemoryWorld world = new MemoryWorld(); EditEngine initial = engine(world, journal); + String id = start(initial, prepare(initial, Map.of(A, STONE))); + initial.persistIntent(nextIntent(initial, id)); + EditEngine recovered = engine(world, journal); block.set(true); + CompletableFuture oldFlush = CompletableFuture.runAsync(() -> { + try { recovered.flushOperation(id); } catch (IOException e) { throw new RuntimeException(e); } + }); + assertTrue(saving.await(2, TimeUnit.SECONDS)); + try { recovered.abandonRecovery(id, recovered.reviewRecovery(id).currentDigest()); } + finally { release.countDown(); } + oldFlush.get(2, TimeUnit.SECONDS); block.set(false); + assertTrue(recovered.status(id).needsFlush()); + Plan next = prepare(recovered, Map.of(B, STONE)); + assertThrows(IllegalStateException.class, () -> recovered.start(next.id(), "stale-flush")); + recovered.flushOperation(id); + assertEquals(OperationStatus.APPLIED, finish(recovered, start(recovered, next)).status()); + } + + @Test void EveryUnresolvedOperationMustBeDurablyAbandonedBeforeNewWrites() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal firstJournal = new MemoryJournal(), secondJournal = new MemoryJournal(); + EditEngine first = engine(world, firstJournal), second = engine(world, secondJournal); + String firstId = start(first, prepare(first, Map.of(A, STONE))); + String secondId = start(second, prepare(second, Map.of(B, STONE))); + first.persistIntent(nextIntent(first, firstId)); second.persistIntent(nextIntent(second, secondId)); + firstJournal.plans.putAll(secondJournal.plans); firstJournal.operations.putAll(secondJournal.operations); + EditEngine recovered = engine(world, firstJournal); + recovered.abandonRecovery(firstId, recovered.reviewRecovery(firstId).currentDigest()); recovered.flushOperation(firstId); + Plan next = prepare(recovered, Map.of(C, STONE)); + assertThrows(IllegalStateException.class, () -> recovered.start(next.id(), "other-unresolved")); + recovered.abandonRecovery(secondId, recovered.reviewRecovery(secondId).currentDigest()); recovered.flushOperation(secondId); + assertEquals(OperationStatus.APPLIED, finish(recovered, start(recovered, next)).status()); + } + + @Test void recoveryCanAbandonWithWritesPausedWhileStillEnforcingContextRights() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal); + String id = start(initial, prepare(initial, Map.of(A, STONE))); + initial.persistIntent(nextIntent(initial, id)); + AtomicBoolean contextAuthorized = new AtomicBoolean(true); + ContextGuard guard = new ContextGuard() { + private void context(Plan plan) { + if (!contextAuthorized.get() || !plan.projectId().equals("project") || !plan.worldEpoch().equals("epoch") + || !plan.region().equals(REGION)) throw new SecurityException("Context or owner no longer authorized"); + } + @Override public void check(Plan plan) { + context(plan); throw new IllegalStateException("Writes paused or part protected"); + } + @Override public void checkRecovery(Plan plan) { context(plan); } + }; + EditEngine recovered = new EditEngine(world, state -> true, guard, journal, LIMITS); + RecoveryReview review = recovered.reviewRecovery(id); + assertEquals(1, recovered.inspectRecovery(id, 0, 1).size()); + contextAuthorized.set(false); + assertThrows(SecurityException.class, () -> recovered.reviewRecovery(id)); + assertThrows(SecurityException.class, () -> recovered.inspectRecovery(id, 0, 1)); + assertThrows(SecurityException.class, () -> recovered.abandonRecovery(id, review.currentDigest())); + assertEquals(OperationStatus.RECOVERY_REQUIRED, recovered.status(id).status()); + contextAuthorized.set(true); + recovered.abandonRecovery(id, review.currentDigest()); recovered.flushOperation(id); + assertEquals(OperationStatus.FAILED, recovered.status(id).status()); assertEquals(0, world.writes); + assertThrows(IllegalStateException.class, () -> prepare(recovered, Map.of(B, STONE))); + } + + @Test void recoveryGuardDefaultNeverSilentlyBypassesExistingAuthorization() throws Exception { + MemoryWorld world = new MemoryWorld(); MemoryJournal journal = new MemoryJournal(); EditEngine initial = engine(world, journal); + String id = start(initial, prepare(initial, Map.of(A, STONE))); + initial.persistIntent(nextIntent(initial, id)); + EditEngine restricted = new EditEngine(world, state -> true, + plan -> { throw new SecurityException("World epoch changed"); }, journal, LIMITS); + assertThrows(SecurityException.class, () -> restricted.reviewRecovery(id)); + assertThrows(SecurityException.class, () -> restricted.inspectRecovery(id, 0, 1)); + assertEquals(OperationStatus.RECOVERY_REQUIRED, restricted.status(id).status()); + assertEquals(0, world.writes); + } +} diff --git a/world-core/src/test/java/io/github/minecraftbuilder/core/JsonJournalTest.java b/world-core/src/test/java/io/github/minecraftbuilder/core/JsonJournalTest.java new file mode 100644 index 0000000..26007ca --- /dev/null +++ b/world-core/src/test/java/io/github/minecraftbuilder/core/JsonJournalTest.java @@ -0,0 +1,55 @@ +package io.github.minecraftbuilder.core; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Map; +import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; +import java.util.HexFormat; +import com.google.gson.JsonParser; +import static org.junit.jupiter.api.Assertions.*; + +class JsonJournalTest { + @TempDir Path temporary; + @Test void roundTripsDurablePlanAndDetectsContentCorruption() throws Exception { + JsonJournal journal = new JsonJournal(temporary); + var engine = EditEngineTest.engine(new EditEngineTest.MemoryWorld(), journal); + Plan plan = EditEngineTest.prepare(engine, Map.of(EditEngineTest.A, EditEngineTest.STONE)); + assertEquals(plan, new JsonJournal(temporary).loadPlans().get(0)); + Path file = temporary.resolve("plans").resolve(plan.id() + ".json"); + Files.writeString(file, Files.readString(file).replace("minecraft:stone", "minecraft:dirt")); + assertThrows(IOException.class, () -> new JsonJournal(temporary).loadPlans()); + } + @Test void strayTemporaryFileDoesNotBecomeACommittedRecord() throws Exception { + JsonJournal journal = new JsonJournal(temporary); + Files.writeString(temporary.resolve("operations/.pending-killed.tmp"), "half a record"); + assertTrue(journal.loadOperations().isEmpty()); + } + @Test void malformedCommittedFileFailsClosed() throws Exception { + JsonJournal journal = new JsonJournal(temporary); + Files.writeString(temporary.resolve("operations/bad.json"), "{truncated"); + assertThrows(IOException.class, journal::loadOperations); + } + + @Test void originalVersionOneOperationWithoutAbandonmentFieldsStillLoads() throws Exception { + JsonJournal journal = new JsonJournal(temporary); + var world = new EditEngineTest.MemoryWorld(); var engine = EditEngineTest.engine(world, journal); + Plan plan = EditEngineTest.prepare(engine, Map.of(EditEngineTest.A, EditEngineTest.STONE)); + String id = EditEngineTest.start(engine, plan); + Path path = temporary.resolve("operations").resolve(id + ".json"); + var envelope = JsonParser.parseString(Files.readString(path)).getAsJsonObject(); + var payload = JsonParser.parseString(envelope.get("payload").getAsString()).getAsJsonObject(); + payload.remove("abandonedRevision"); payload.remove("abandonedPositions"); + String originalPayload = payload.toString(); + envelope.addProperty("payload", originalPayload); + envelope.addProperty("sha256", HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(originalPayload.getBytes(StandardCharsets.UTF_8)))); + Files.writeString(path, envelope.toString()); + var restarted = EditEngineTest.engine(world, new JsonJournal(temporary)); + assertEquals(OperationStatus.RECOVERY_REQUIRED, restarted.status(id).status()); + assertEquals(0, restarted.reviewRecovery(id).positions()); + } +} diff --git a/world-core/src/test/java/io/github/minecraftbuilder/core/RecipeCompilerTest.java b/world-core/src/test/java/io/github/minecraftbuilder/core/RecipeCompilerTest.java new file mode 100644 index 0000000..638db94 --- /dev/null +++ b/world-core/src/test/java/io/github/minecraftbuilder/core/RecipeCompilerTest.java @@ -0,0 +1,68 @@ +package io.github.minecraftbuilder.core; + +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Test; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.*; + +class RecipeCompilerTest { + private static Map compile(String operations, int limit) { + return RecipeCompiler.compile(JsonParser.parseString("{\"version\":1,\"operations\":" + operations + "}").getAsJsonObject(), limit); + } + @Test void hollowBoxHasFacesAndNoInterior() { + var blocks = compile(""" + [{"type":"box","min":{"x":0,"y":0,"z":0},"max":{"x":2,"y":2,"z":2},"block":"minecraft:stone","hollow":true}] + """, 100); + assertEquals(26, blocks.size()); assertFalse(blocks.containsKey(new BlockPos(1, 1, 1))); + } + @Test void repeatUsesOffsetsAndLaterOperationsWinDeterministically() { + String json = """ + [{"type":"repeat","count":3,"offset":{"x":2,"y":0,"z":0},"operations":[ + {"type":"line","from":{"x":0,"y":0,"z":0},"to":{"x":1,"y":0,"z":0},"block":"minecraft:stone"} + ]},{"type":"box","min":{"x":2,"y":0,"z":0},"max":{"x":2,"y":0,"z":0},"block":"minecraft:gold_block"}] + """; + var blocks = compile(json, 20); assertEquals(6, blocks.size()); + assertEquals("minecraft:gold_block", blocks.get(new BlockPos(2, 0, 0))); assertEquals(blocks, compile(json, 20)); + } + @Test void descendingDiagonalIncludesExactEndpoints() { + var blocks = compile(""" + [{"type":"line","from":{"x":3,"y":3,"z":3},"to":{"x":-2,"y":-2,"z":-2},"block":"minecraft:stone"}] + """, 10); + assertEquals(6, blocks.size()); assertTrue(blocks.containsKey(new BlockPos(-2, -2, -2))); + assertTrue(blocks.containsKey(new BlockPos(3, 3, 3))); + } + @Test void cylinderUsesBottomCenterAndOpenShell() { + var solid = compile(""" + [{"type":"cylinder","center":{"x":0,"y":3,"z":0},"radius":1,"height":2,"block":"minecraft:stone"}] + """, 20); + assertEquals(10, solid.size()); assertTrue(solid.containsKey(new BlockPos(0, 4, 0))); + var hollow = compile(""" + [{"type":"cylinder","center":{"x":0,"y":3,"z":0},"radius":1,"height":2,"block":"minecraft:stone","hollow":true}] + """, 20); + assertEquals(8, hollow.size()); assertFalse(hollow.containsKey(new BlockPos(0, 3, 0))); + } + @Test void boundsAndScanBudgetRejectHugeOrOverlappingRecipes() { + assertThrows(IllegalArgumentException.class, () -> compile(""" + [{"type":"box","min":{"x":0,"y":0,"z":0},"max":{"x":1000,"y":1000,"z":1000},"block":"minecraft:stone"}] + """, 100)); + assertThrows(IllegalArgumentException.class, () -> compile(""" + [{"type":"repeat","count":100,"offset":{"x":0,"y":0,"z":0},"operations":[ + {"type":"box","min":{"x":0,"y":0,"z":0},"max":{"x":0,"y":0,"z":0},"block":"minecraft:stone"}]}] + """, 1)); + assertThrows(IllegalArgumentException.class, () -> compile(""" + [{"type":"line","from":{"x":0,"y":0,"z":0},"to":{"x":2,"y":0,"z":0},"block":"minecraft:stone"}] + """, 2)); + } + @Test void rejectsFractionalCoordinatesUnknownFieldsAndCoordinateOverflow() { + assertThrows(IllegalArgumentException.class, () -> compile(""" + [{"type":"line","from":{"x":0.5,"y":0,"z":0},"to":{"x":1,"y":0,"z":0},"block":"minecraft:stone"}] + """, 20)); + assertThrows(IllegalArgumentException.class, () -> compile(""" + [{"type":"repeat","count":1,"offset":{"x":0,"y":0,"z":0},"operations":[],"script":"shell"}] + """, 20)); + assertThrows(ArithmeticException.class, () -> compile(""" + [{"type":"repeat","count":2,"offset":{"x":1,"y":0,"z":0},"operations":[ + {"type":"box","min":{"x":2147483647,"y":0,"z":0},"max":{"x":2147483647,"y":0,"z":0},"block":"minecraft:stone"}]}] + """, 20)); + } +}