From 8f616598fdb0f7cc89f7a80b48fa2f8c66c2d39d Mon Sep 17 00:00:00 2001 From: roberts Date: Mon, 30 Mar 2026 12:33:17 -0500 Subject: [PATCH] Fix chat, brain stability, MariaDB reconnect, suppress warnings - Listen on raw 'text' packet for Bedrock chat (pattern-based chat event doesn't fire reliably on Bedrock) - Brain: add safety reset for stuck pending_status flag - MariaDB: add retry-on-disconnect for all query methods - Suppress harmless punycode deprecation warning from Node.js - Add mineflayer-bedrock lib packages (mineflayer, prismarine-chunk, prismarine-registry) for movement support - Exclude minecraft-data from git (278MB, installed via npm) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 2 + bridge/.gitignore | 2 + bridge/lib/mineflayer/.github/FUNDING.yml | 4 + .../.github/ISSUE_TEMPLATE/bug_report.md | 40 + .../.github/ISSUE_TEMPLATE/config.yml | 5 + .../.github/ISSUE_TEMPLATE/feature_request.md | 20 + bridge/lib/mineflayer/.github/dependabot.yml | 11 + .../lib/mineflayer/.github/workflows/ci.yml | 60 + .../mineflayer/.github/workflows/commands.yml | 22 + .../.github/workflows/npm-publish.yml | 32 + bridge/lib/mineflayer/.gitignore | 13 + bridge/lib/mineflayer/.gitpod.yml | 2 + bridge/lib/mineflayer/.npmignore | 3 + bridge/lib/mineflayer/.npmrc | 2 + bridge/lib/mineflayer/LICENSE | 21 + bridge/lib/mineflayer/bedrock-types.d.ts | 6351 +++++++++++++++++ bridge/lib/mineflayer/index.d.ts | 878 +++ bridge/lib/mineflayer/index.js | 7 + bridge/lib/mineflayer/lib/BlobStore.js | 54 + .../lib/mineflayer/lib/bedrock/container.mts | 301 + .../mineflayer/lib/bedrock/crafting-core.mts | 652 ++ bridge/lib/mineflayer/lib/bedrock/index.mts | 157 + .../lib/bedrock/item-stack-actions.mts | 624 ++ .../mineflayer/lib/bedrock/slot-mapping.mts | 235 + .../lib/bedrock/workstations/anvil.mts | 315 + .../lib/bedrock/workstations/brewing.mts | 211 + .../lib/bedrock/workstations/cartography.mts | 206 + .../lib/bedrock/workstations/enchanting.mts | 368 + .../lib/bedrock/workstations/furnace.mts | 216 + .../lib/bedrock/workstations/grindstone.mts | 167 + .../lib/bedrock/workstations/index.mts | 13 + .../lib/bedrock/workstations/loom.mts | 209 + .../lib/bedrock/workstations/smithing.mts | 253 + .../lib/bedrock/workstations/stonecutter.mts | 142 + .../lib/bedrockPlugins/attribute-patch.js | 30 + .../lib/mineflayer/lib/bedrockPlugins/bed.mts | 229 + .../lib/bedrockPlugins/block_actions.mts | 128 + .../mineflayer/lib/bedrockPlugins/blocks.mts | 715 ++ .../mineflayer/lib/bedrockPlugins/book.mts | 184 + .../mineflayer/lib/bedrockPlugins/bossbar.mts | 61 + .../mineflayer/lib/bedrockPlugins/breath.mts | 17 + .../mineflayer/lib/bedrockPlugins/chat.mts | 299 + .../mineflayer/lib/bedrockPlugins/chest.mts | 113 + .../mineflayer/lib/bedrockPlugins/craft.mts | 1168 +++ .../lib/bedrockPlugins/creative.mts | 304 + .../mineflayer/lib/bedrockPlugins/digging.mts | 366 + .../lib/bedrockPlugins/entities.mts | 601 ++ .../lib/bedrockPlugins/experience.mts | 23 + .../mineflayer/lib/bedrockPlugins/fishing.mts | 91 + .../mineflayer/lib/bedrockPlugins/game.mts | 172 + .../mineflayer/lib/bedrockPlugins/health.mts | 131 + .../lib/bedrockPlugins/input-data-service.mts | 103 + .../lib/bedrockPlugins/inventory.mts | 1635 +++++ .../lib/bedrockPlugins/inventory_minimal.mts | 81 + .../mineflayer/lib/bedrockPlugins/kick.mts | 12 + .../lib/bedrockPlugins/particle.mts | 18 + .../mineflayer/lib/bedrockPlugins/physics.mts | 601 ++ .../mineflayer/lib/bedrockPlugins/rain.mts | 22 + .../lib/bedrockPlugins/resource_pack.mts | 15 + .../lib/bedrockPlugins/scoreboard.mts | 64 + .../lib/bedrockPlugins/simple_inventory.mts | 264 + .../mineflayer/lib/bedrockPlugins/sound.mts | 31 + .../lib/bedrockPlugins/spawn_point.mts | 17 + .../mineflayer/lib/bedrockPlugins/tablist.mts | 12 + .../mineflayer/lib/bedrockPlugins/team.mts | 7 + .../mineflayer/lib/bedrockPlugins/time.mts | 63 + .../mineflayer/lib/bedrockPlugins/title.mts | 12 + .../lib/bedrockPlugins/villager.mts | 449 ++ bridge/lib/mineflayer/lib/bossbar.js | 109 + bridge/lib/mineflayer/lib/conversions.js | 40 + .../lib/inventory-packet-logger.mts | 36 + bridge/lib/mineflayer/lib/loader.js | 251 + bridge/lib/mineflayer/lib/location.js | 14 + .../mineflayer/lib/logger/logger-colors.mts | 36 + bridge/lib/mineflayer/lib/logger/logger.mts | 172 + .../lib/logger/minecraft-colors.mts | 60 + bridge/lib/mineflayer/lib/math.js | 8 + bridge/lib/mineflayer/lib/painting.js | 7 + bridge/lib/mineflayer/lib/particle.js | 44 + bridge/lib/mineflayer/lib/plugin_loader.js | 52 + bridge/lib/mineflayer/lib/plugins/anvil.js | 115 + bridge/lib/mineflayer/lib/plugins/bed.js | 194 + .../mineflayer/lib/plugins/block_actions.js | 113 + bridge/lib/mineflayer/lib/plugins/blocks.js | 608 ++ bridge/lib/mineflayer/lib/plugins/book.js | 101 + bridge/lib/mineflayer/lib/plugins/boss_bar.js | 63 + bridge/lib/mineflayer/lib/plugins/breath.js | 19 + bridge/lib/mineflayer/lib/plugins/chat.js | 221 + bridge/lib/mineflayer/lib/plugins/chest.js | 33 + .../mineflayer/lib/plugins/command_block.js | 128 + bridge/lib/mineflayer/lib/plugins/craft.js | 243 + bridge/lib/mineflayer/lib/plugins/creative.js | 112 + bridge/lib/mineflayer/lib/plugins/digging.js | 264 + .../lib/plugins/enchantment_table.js | 103 + bridge/lib/mineflayer/lib/plugins/entities.js | 956 +++ .../lib/mineflayer/lib/plugins/experience.js | 15 + .../lib/mineflayer/lib/plugins/explosion.js | 93 + bridge/lib/mineflayer/lib/plugins/fishing.js | 61 + bridge/lib/mineflayer/lib/plugins/furnace.js | 121 + bridge/lib/mineflayer/lib/plugins/game.js | 142 + .../mineflayer/lib/plugins/generic_place.js | 108 + bridge/lib/mineflayer/lib/plugins/health.js | 41 + .../lib/mineflayer/lib/plugins/inventory.js | 786 ++ bridge/lib/mineflayer/lib/plugins/kick.js | 14 + bridge/lib/mineflayer/lib/plugins/particle.js | 9 + bridge/lib/mineflayer/lib/plugins/physics.js | 453 ++ .../lib/mineflayer/lib/plugins/place_block.js | 38 + .../mineflayer/lib/plugins/place_entity.js | 109 + bridge/lib/mineflayer/lib/plugins/rain.js | 24 + .../lib/mineflayer/lib/plugins/ray_trace.js | 66 + .../mineflayer/lib/plugins/resource_pack.js | 94 + .../lib/mineflayer/lib/plugins/scoreboard.js | 75 + bridge/lib/mineflayer/lib/plugins/settings.js | 107 + .../lib/plugins/simple_inventory.js | 156 + bridge/lib/mineflayer/lib/plugins/sound.js | 48 + .../lib/mineflayer/lib/plugins/spawn_point.js | 11 + bridge/lib/mineflayer/lib/plugins/tablist.js | 31 + bridge/lib/mineflayer/lib/plugins/team.js | 90 + bridge/lib/mineflayer/lib/plugins/time.js | 38 + bridge/lib/mineflayer/lib/plugins/title.js | 37 + bridge/lib/mineflayer/lib/plugins/villager.js | 240 + bridge/lib/mineflayer/lib/promise_utils.js | 95 + bridge/lib/mineflayer/lib/scoreboard.js | 65 + bridge/lib/mineflayer/lib/team.js | 86 + bridge/lib/mineflayer/lib/version.js | 18 + bridge/lib/mineflayer/package.json | 58 + bridge/lib/mineflayer/tsconfig.json | 10 + .../prismarine-chunk/.github/dependabot.yml | 11 + .../prismarine-chunk/.github/workflows/ci.yml | 25 + .../.github/workflows/commands.yml | 22 + .../.github/workflows/npm-publish.yml | 32 + bridge/lib/prismarine-chunk/.gitignore | 7 + bridge/lib/prismarine-chunk/.gitpod.yml | 2 + bridge/lib/prismarine-chunk/.npmignore | 2 + bridge/lib/prismarine-chunk/.npmrc | 1 + bridge/lib/prismarine-chunk/HISTORY.md | 260 + bridge/lib/prismarine-chunk/LICENSE | 21 + bridge/lib/prismarine-chunk/README.md | 260 + bridge/lib/prismarine-chunk/example.js | 14 + bridge/lib/prismarine-chunk/index.js | 1 + bridge/lib/prismarine-chunk/package.json | 60 + .../src/bedrock/0.14/chunk.js | 213 + .../prismarine-chunk/src/bedrock/1.0/chunk.js | 227 + .../src/bedrock/1.0/subchunk.js | 76 + .../src/bedrock/1.18/BiomeSection.js | 131 + .../src/bedrock/1.18/ChunkColumn.js | 301 + .../src/bedrock/1.18/ProxyBiomeSection.js | 47 + .../src/bedrock/1.18/SubChunk.js | 38 + .../src/bedrock/1.18/chunk.js | 19 + .../src/bedrock/1.3/ChunkColumn.js | 277 + .../src/bedrock/1.3/SubChunk.js | 365 + .../prismarine-chunk/src/bedrock/1.3/chunk.js | 20 + .../src/bedrock/common/BlobCache.js | 17 + .../src/bedrock/common/CommonChunkColumn.js | 265 + .../src/bedrock/common/PalettedStorage.js | 106 + .../src/bedrock/common/Stream.js | 369 + .../src/bedrock/common/constants.js | 42 + .../src/bedrock/common/util.js | 16 + bridge/lib/prismarine-chunk/src/index.js | 54 + .../src/pc/1.13/ChunkColumn.js | 288 + .../src/pc/1.13/ChunkSection.js | 196 + .../lib/prismarine-chunk/src/pc/1.13/chunk.js | 13 + .../src/pc/1.14/ChunkColumn.js | 364 + .../lib/prismarine-chunk/src/pc/1.14/chunk.js | 13 + .../src/pc/1.15/ChunkColumn.js | 342 + .../lib/prismarine-chunk/src/pc/1.15/chunk.js | 13 + .../src/pc/1.16/ChunkColumn.js | 342 + .../lib/prismarine-chunk/src/pc/1.16/chunk.js | 13 + .../src/pc/1.17/ChunkColumn.js | 393 + .../lib/prismarine-chunk/src/pc/1.17/chunk.js | 7 + .../src/pc/1.18/ChunkColumn.js | 384 + .../lib/prismarine-chunk/src/pc/1.18/chunk.js | 7 + .../lib/prismarine-chunk/src/pc/1.8/chunk.js | 244 + .../prismarine-chunk/src/pc/1.8/section.js | 143 + .../src/pc/1.9/ChunkColumn.js | 274 + .../src/pc/1.9/ChunkSection.js | 199 + .../lib/prismarine-chunk/src/pc/1.9/chunk.js | 12 + .../src/pc/common/BitArray.js | 161 + .../src/pc/common/BitArrayNoSpan.js | 217 + .../src/pc/common/CommonChunkColumn.js | 71 + .../src/pc/common/CommonChunkSection.js | 155 + .../src/pc/common/PaletteBiome.js | 110 + .../src/pc/common/PaletteChunkSection.js | 150 + .../src/pc/common/PaletteContainer.js | 239 + .../src/pc/common/constants.js | 52 + .../src/pc/common/neededBits.js | 10 + .../prismarine-chunk/src/pc/common/varInt.js | 45 + .../tools/generate-bedrock-test-data.mjs | 426 ++ bridge/lib/prismarine-chunk/types/index.d.ts | 228 + .../lib/prismarine-chunk/types/section.d.ts | 47 + .../.github/ISSUE_TEMPLATE/bug_report.md | 32 + .../.github/ISSUE_TEMPLATE/feature_request.md | 20 + .../.github/ISSUE_TEMPLATE/question.md | 46 + .../.github/dependabot.yml | 7 + .../.github/workflows/ci.yml | 36 + .../.github/workflows/commands.yml | 22 + .../.github/workflows/publish.yml | 32 + bridge/lib/prismarine-registry/.gitignore | 6 + bridge/lib/prismarine-registry/.gitpod | 4 + .../prismarine-registry/.gitpod.DockerFile | 10 + bridge/lib/prismarine-registry/.npmrc | 1 + bridge/lib/prismarine-registry/HISTORY.md | 50 + bridge/lib/prismarine-registry/LICENSE | 21 + bridge/lib/prismarine-registry/README.md | 75 + bridge/lib/prismarine-registry/example.js | 3 + .../prismarine-registry/lib/bedrock/index.js | 128 + bridge/lib/prismarine-registry/lib/index.d.ts | 25 + bridge/lib/prismarine-registry/lib/index.js | 18 + bridge/lib/prismarine-registry/lib/indexer.js | 7 + bridge/lib/prismarine-registry/lib/loader.js | 4 + .../lib/prismarine-registry/lib/pc/index.js | 146 + .../prismarine-registry/lib/pc/transforms.js | 75 + bridge/lib/prismarine-registry/package.json | 41 + bridge/src/index.js | 15 +- dougbot/bridge/node_manager.py | 10 +- dougbot/core/brain.py | 5 + dougbot/db/connection.py | 41 +- 217 files changed, 36399 insertions(+), 17 deletions(-) create mode 100644 bridge/.gitignore create mode 100644 bridge/lib/mineflayer/.github/FUNDING.yml create mode 100644 bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/bug_report.md create mode 100644 bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/config.yml create mode 100644 bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/feature_request.md create mode 100644 bridge/lib/mineflayer/.github/dependabot.yml create mode 100644 bridge/lib/mineflayer/.github/workflows/ci.yml create mode 100644 bridge/lib/mineflayer/.github/workflows/commands.yml create mode 100644 bridge/lib/mineflayer/.github/workflows/npm-publish.yml create mode 100644 bridge/lib/mineflayer/.gitignore create mode 100644 bridge/lib/mineflayer/.gitpod.yml create mode 100644 bridge/lib/mineflayer/.npmignore create mode 100644 bridge/lib/mineflayer/.npmrc create mode 100644 bridge/lib/mineflayer/LICENSE create mode 100644 bridge/lib/mineflayer/bedrock-types.d.ts create mode 100644 bridge/lib/mineflayer/index.d.ts create mode 100644 bridge/lib/mineflayer/index.js create mode 100644 bridge/lib/mineflayer/lib/BlobStore.js create mode 100644 bridge/lib/mineflayer/lib/bedrock/container.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/crafting-core.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/index.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/item-stack-actions.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/slot-mapping.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/anvil.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/brewing.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/cartography.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/enchanting.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/furnace.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/grindstone.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/index.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/loom.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/smithing.mts create mode 100644 bridge/lib/mineflayer/lib/bedrock/workstations/stonecutter.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/attribute-patch.js create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/bed.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/block_actions.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/blocks.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/book.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/bossbar.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/breath.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/chat.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/chest.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/craft.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/creative.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/digging.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/entities.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/experience.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/fishing.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/game.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/health.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/input-data-service.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/inventory.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/inventory_minimal.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/kick.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/particle.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/physics.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/rain.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/resource_pack.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/scoreboard.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/simple_inventory.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/sound.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/spawn_point.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/tablist.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/team.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/time.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/title.mts create mode 100644 bridge/lib/mineflayer/lib/bedrockPlugins/villager.mts create mode 100644 bridge/lib/mineflayer/lib/bossbar.js create mode 100644 bridge/lib/mineflayer/lib/conversions.js create mode 100644 bridge/lib/mineflayer/lib/inventory-packet-logger.mts create mode 100644 bridge/lib/mineflayer/lib/loader.js create mode 100644 bridge/lib/mineflayer/lib/location.js create mode 100644 bridge/lib/mineflayer/lib/logger/logger-colors.mts create mode 100644 bridge/lib/mineflayer/lib/logger/logger.mts create mode 100644 bridge/lib/mineflayer/lib/logger/minecraft-colors.mts create mode 100644 bridge/lib/mineflayer/lib/math.js create mode 100644 bridge/lib/mineflayer/lib/painting.js create mode 100644 bridge/lib/mineflayer/lib/particle.js create mode 100644 bridge/lib/mineflayer/lib/plugin_loader.js create mode 100644 bridge/lib/mineflayer/lib/plugins/anvil.js create mode 100644 bridge/lib/mineflayer/lib/plugins/bed.js create mode 100644 bridge/lib/mineflayer/lib/plugins/block_actions.js create mode 100644 bridge/lib/mineflayer/lib/plugins/blocks.js create mode 100644 bridge/lib/mineflayer/lib/plugins/book.js create mode 100644 bridge/lib/mineflayer/lib/plugins/boss_bar.js create mode 100644 bridge/lib/mineflayer/lib/plugins/breath.js create mode 100644 bridge/lib/mineflayer/lib/plugins/chat.js create mode 100644 bridge/lib/mineflayer/lib/plugins/chest.js create mode 100644 bridge/lib/mineflayer/lib/plugins/command_block.js create mode 100644 bridge/lib/mineflayer/lib/plugins/craft.js create mode 100644 bridge/lib/mineflayer/lib/plugins/creative.js create mode 100644 bridge/lib/mineflayer/lib/plugins/digging.js create mode 100644 bridge/lib/mineflayer/lib/plugins/enchantment_table.js create mode 100644 bridge/lib/mineflayer/lib/plugins/entities.js create mode 100644 bridge/lib/mineflayer/lib/plugins/experience.js create mode 100644 bridge/lib/mineflayer/lib/plugins/explosion.js create mode 100644 bridge/lib/mineflayer/lib/plugins/fishing.js create mode 100644 bridge/lib/mineflayer/lib/plugins/furnace.js create mode 100644 bridge/lib/mineflayer/lib/plugins/game.js create mode 100644 bridge/lib/mineflayer/lib/plugins/generic_place.js create mode 100644 bridge/lib/mineflayer/lib/plugins/health.js create mode 100644 bridge/lib/mineflayer/lib/plugins/inventory.js create mode 100644 bridge/lib/mineflayer/lib/plugins/kick.js create mode 100644 bridge/lib/mineflayer/lib/plugins/particle.js create mode 100644 bridge/lib/mineflayer/lib/plugins/physics.js create mode 100644 bridge/lib/mineflayer/lib/plugins/place_block.js create mode 100644 bridge/lib/mineflayer/lib/plugins/place_entity.js create mode 100644 bridge/lib/mineflayer/lib/plugins/rain.js create mode 100644 bridge/lib/mineflayer/lib/plugins/ray_trace.js create mode 100644 bridge/lib/mineflayer/lib/plugins/resource_pack.js create mode 100644 bridge/lib/mineflayer/lib/plugins/scoreboard.js create mode 100644 bridge/lib/mineflayer/lib/plugins/settings.js create mode 100644 bridge/lib/mineflayer/lib/plugins/simple_inventory.js create mode 100644 bridge/lib/mineflayer/lib/plugins/sound.js create mode 100644 bridge/lib/mineflayer/lib/plugins/spawn_point.js create mode 100644 bridge/lib/mineflayer/lib/plugins/tablist.js create mode 100644 bridge/lib/mineflayer/lib/plugins/team.js create mode 100644 bridge/lib/mineflayer/lib/plugins/time.js create mode 100644 bridge/lib/mineflayer/lib/plugins/title.js create mode 100644 bridge/lib/mineflayer/lib/plugins/villager.js create mode 100644 bridge/lib/mineflayer/lib/promise_utils.js create mode 100644 bridge/lib/mineflayer/lib/scoreboard.js create mode 100644 bridge/lib/mineflayer/lib/team.js create mode 100644 bridge/lib/mineflayer/lib/version.js create mode 100644 bridge/lib/mineflayer/package.json create mode 100644 bridge/lib/mineflayer/tsconfig.json create mode 100644 bridge/lib/prismarine-chunk/.github/dependabot.yml create mode 100644 bridge/lib/prismarine-chunk/.github/workflows/ci.yml create mode 100644 bridge/lib/prismarine-chunk/.github/workflows/commands.yml create mode 100644 bridge/lib/prismarine-chunk/.github/workflows/npm-publish.yml create mode 100644 bridge/lib/prismarine-chunk/.gitignore create mode 100644 bridge/lib/prismarine-chunk/.gitpod.yml create mode 100644 bridge/lib/prismarine-chunk/.npmignore create mode 100644 bridge/lib/prismarine-chunk/.npmrc create mode 100644 bridge/lib/prismarine-chunk/HISTORY.md create mode 100644 bridge/lib/prismarine-chunk/LICENSE create mode 100644 bridge/lib/prismarine-chunk/README.md create mode 100644 bridge/lib/prismarine-chunk/example.js create mode 100644 bridge/lib/prismarine-chunk/index.js create mode 100644 bridge/lib/prismarine-chunk/package.json create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/0.14/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.0/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.0/subchunk.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.18/BiomeSection.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.18/ChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.18/ProxyBiomeSection.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.18/SubChunk.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.18/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.3/ChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.3/SubChunk.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/1.3/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/common/BlobCache.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/common/CommonChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/common/PalettedStorage.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/common/Stream.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/common/constants.js create mode 100644 bridge/lib/prismarine-chunk/src/bedrock/common/util.js create mode 100644 bridge/lib/prismarine-chunk/src/index.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.13/ChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.13/ChunkSection.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.13/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.14/ChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.14/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.15/ChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.15/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.16/ChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.16/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.17/ChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.17/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.18/ChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.18/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.8/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.8/section.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.9/ChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.9/ChunkSection.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/1.9/chunk.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/BitArray.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/BitArrayNoSpan.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/CommonChunkColumn.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/CommonChunkSection.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/PaletteBiome.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/PaletteChunkSection.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/PaletteContainer.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/constants.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/neededBits.js create mode 100644 bridge/lib/prismarine-chunk/src/pc/common/varInt.js create mode 100644 bridge/lib/prismarine-chunk/tools/generate-bedrock-test-data.mjs create mode 100644 bridge/lib/prismarine-chunk/types/index.d.ts create mode 100644 bridge/lib/prismarine-chunk/types/section.d.ts create mode 100644 bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/bug_report.md create mode 100644 bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/feature_request.md create mode 100644 bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/question.md create mode 100644 bridge/lib/prismarine-registry/.github/dependabot.yml create mode 100644 bridge/lib/prismarine-registry/.github/workflows/ci.yml create mode 100644 bridge/lib/prismarine-registry/.github/workflows/commands.yml create mode 100644 bridge/lib/prismarine-registry/.github/workflows/publish.yml create mode 100644 bridge/lib/prismarine-registry/.gitignore create mode 100644 bridge/lib/prismarine-registry/.gitpod create mode 100644 bridge/lib/prismarine-registry/.gitpod.DockerFile create mode 100644 bridge/lib/prismarine-registry/.npmrc create mode 100644 bridge/lib/prismarine-registry/HISTORY.md create mode 100644 bridge/lib/prismarine-registry/LICENSE create mode 100644 bridge/lib/prismarine-registry/README.md create mode 100644 bridge/lib/prismarine-registry/example.js create mode 100644 bridge/lib/prismarine-registry/lib/bedrock/index.js create mode 100644 bridge/lib/prismarine-registry/lib/index.d.ts create mode 100644 bridge/lib/prismarine-registry/lib/index.js create mode 100644 bridge/lib/prismarine-registry/lib/indexer.js create mode 100644 bridge/lib/prismarine-registry/lib/loader.js create mode 100644 bridge/lib/prismarine-registry/lib/pc/index.js create mode 100644 bridge/lib/prismarine-registry/lib/pc/transforms.js create mode 100644 bridge/lib/prismarine-registry/package.json diff --git a/.gitignore b/.gitignore index 23dc199..47ea803 100644 --- a/.gitignore +++ b/.gitignore @@ -3,6 +3,8 @@ __pycache__/ .env node_modules/ bridge/dist/ +bridge/lib/minecraft-data/ +bridge/lib/*/node_modules/ *.egg-info/ .eggs/ dist/ diff --git a/bridge/.gitignore b/bridge/.gitignore new file mode 100644 index 0000000..243b952 --- /dev/null +++ b/bridge/.gitignore @@ -0,0 +1,2 @@ +bridge/lib/ +bridge/node_modules/ diff --git a/bridge/lib/mineflayer/.github/FUNDING.yml b/bridge/lib/mineflayer/.github/FUNDING.yml new file mode 100644 index 0000000..5a65f93 --- /dev/null +++ b/bridge/lib/mineflayer/.github/FUNDING.yml @@ -0,0 +1,4 @@ +# These are supported funding model platforms +open_collective: prismarinejs +custom: https://rysolv.com/repos/detail/74691b23-938d-4b2f-b65a-5c47bf5b3f0f +github: PrismarineJS diff --git a/bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/bug_report.md b/bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..b71192f --- /dev/null +++ b/bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,40 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: possible bug,Stage1 +assignees: '' + +--- + +- [ ] The [FAQ](https://github.com/PrismarineJS/mineflayer/blob/master/docs/FAQ.md) doesn't contain a resolution to my issue + +## Versions + - mineflayer: #.#.# + - server: vanilla/spigot/paper #.#.# + - node: #.#.# + +## Detailed description of a problem +A clear and concise description of what the problem is, with as much context as possible. +What are you building? What problem are you trying to solve? + +## What did you try yet? + +Did you try any method from the API? +Did you try any example? Any error from those? + +## Your current code +```js + +/* +Some code here, replace this +*/ + + +``` + +## Expected behavior +A clear and concise description of what you expected to happen. + +## Additional context +Add any other context about the problem here. diff --git a/bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/config.yml b/bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..e30813e --- /dev/null +++ b/bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,5 @@ +blank_issues_enabled: false +contact_links: + - name: Github Discussions + url: https://github.com/PrismarineJS/mineflayer/discussions + about: Please make a post in our Github Discussions for questions and support requests. diff --git a/bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/feature_request.md b/bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..cf00abe --- /dev/null +++ b/bridge/lib/mineflayer/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: new feature,Stage1 +assignees: '' + +--- + +## Is your feature request related to a problem? Please describe. +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +## Describe the solution you'd like +A clear and concise description of what you want to happen. + +## Describe alternatives you've considered +A clear and concise description of any alternative solutions or features you've considered. + +## Additional context +Add any other context or screenshots about the feature request here. diff --git a/bridge/lib/mineflayer/.github/dependabot.yml b/bridge/lib/mineflayer/.github/dependabot.yml new file mode 100644 index 0000000..77b6cbf --- /dev/null +++ b/bridge/lib/mineflayer/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + ignore: + - dependency-name: "@types/node" + versions: + - 15.0.0 diff --git a/bridge/lib/mineflayer/.github/workflows/ci.yml b/bridge/lib/mineflayer/.github/workflows/ci.yml new file mode 100644 index 0000000..54a1429 --- /dev/null +++ b/bridge/lib/mineflayer/.github/workflows/ci.yml @@ -0,0 +1,60 @@ +name: CI + +on: + push: + branches: + - master + pull_request: + +jobs: + Lint: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 22.x + uses: actions/setup-node@v1.4.4 + with: + node-version: 22.x + - run: npm i && npm run lint + + PrepareSupportedVersions: + runs-on: ubuntu-latest + outputs: + matrix: ${{ steps.set-matrix.outputs.matrix }} + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 22.x + uses: actions/setup-node@v1.4.4 + with: + node-version: 22.x + - id: set-matrix + run: | + node -e " + const testedVersions = require('./lib/version').testedVersions; + console.log('matrix='+JSON.stringify({'include': testedVersions.map(mcVersion => ({mcVersion}))})) + " >> $GITHUB_OUTPUT + + MinecraftServer: + needs: PrepareSupportedVersions + runs-on: ubuntu-latest + strategy: + matrix: ${{fromJson(needs.PrepareSupportedVersions.outputs.matrix)}} + fail-fast: false + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js 22.x + uses: actions/setup-node@v1.4.4 + with: + node-version: 22.x + - name: Setup Java JDK + uses: actions/setup-java@v1.4.3 + with: + java-version: 21 + java-package: jre + - name: Install Dependencies + run: npm install + - name: Start Tests + run: npm run mocha_test -- -g ${{ matrix.mcVersion }}v diff --git a/bridge/lib/mineflayer/.github/workflows/commands.yml b/bridge/lib/mineflayer/.github/workflows/commands.yml new file mode 100644 index 0000000..d2286e2 --- /dev/null +++ b/bridge/lib/mineflayer/.github/workflows/commands.yml @@ -0,0 +1,22 @@ +name: Repo Commands + +on: + issue_comment: # Handle comment commands + types: [created] + pull_request: # Handle renamed PRs + types: [edited] + +jobs: + comment-trigger: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - name: Run command handlers + uses: PrismarineJS/prismarine-repo-actions@master + with: + # NOTE: You must specify a Personal Access Token (PAT) with repo access here. While you can use the default GITHUB_TOKEN, actions taken with it will not trigger other actions, so if you have a CI workflow, commits created by this action will not trigger it. + token: ${{ secrets.PAT_PASSWORD }} + # See `Options` section below for more info on these options + install-command: npm install + /fixlint.fix-command: npm run fix \ No newline at end of file diff --git a/bridge/lib/mineflayer/.github/workflows/npm-publish.yml b/bridge/lib/mineflayer/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..95e11fb --- /dev/null +++ b/bridge/lib/mineflayer/.github/workflows/npm-publish.yml @@ -0,0 +1,32 @@ +name: npm-publish +on: + push: + branches: + - master # Change this to your default branch +jobs: + npm-publish: + name: npm-publish + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@master + - name: Set up Node.js + uses: actions/setup-node@master + with: + node-version: 22.0.0 + - id: publish + uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_AUTH_TOKEN }} + - name: Create Release + if: steps.publish.outputs.type != 'none' + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.publish.outputs.version }} + release_name: Release ${{ steps.publish.outputs.version }} + body: ${{ steps.publish.outputs.version }} + draft: false + prerelease: false diff --git a/bridge/lib/mineflayer/.gitignore b/bridge/lib/mineflayer/.gitignore new file mode 100644 index 0000000..0089726 --- /dev/null +++ b/bridge/lib/mineflayer/.gitignore @@ -0,0 +1,13 @@ +# shared with .npmignore +# different than .npmignore +node_modules +package-lock.json +yarn.lock +/README.md +versions/ +server_jars/ +test/server_* +.vscode +.DS_Store +launcher_accounts.json +data \ No newline at end of file diff --git a/bridge/lib/mineflayer/.gitpod.yml b/bridge/lib/mineflayer/.gitpod.yml new file mode 100644 index 0000000..13f366c --- /dev/null +++ b/bridge/lib/mineflayer/.gitpod.yml @@ -0,0 +1,2 @@ +tasks: +- command: npm install && sdk install java diff --git a/bridge/lib/mineflayer/.npmignore b/bridge/lib/mineflayer/.npmignore new file mode 100644 index 0000000..0418e13 --- /dev/null +++ b/bridge/lib/mineflayer/.npmignore @@ -0,0 +1,3 @@ +# shared with .gitignore +# different than .gitignore +test diff --git a/bridge/lib/mineflayer/.npmrc b/bridge/lib/mineflayer/.npmrc new file mode 100644 index 0000000..4c1bf77 --- /dev/null +++ b/bridge/lib/mineflayer/.npmrc @@ -0,0 +1,2 @@ +engine-strict=true +package-lock=false diff --git a/bridge/lib/mineflayer/LICENSE b/bridge/lib/mineflayer/LICENSE new file mode 100644 index 0000000..51a1368 --- /dev/null +++ b/bridge/lib/mineflayer/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2015 Andrew Kelley + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bridge/lib/mineflayer/bedrock-types.d.ts b/bridge/lib/mineflayer/bedrock-types.d.ts new file mode 100644 index 0000000..ddc51b9 --- /dev/null +++ b/bridge/lib/mineflayer/bedrock-types.d.ts @@ -0,0 +1,6351 @@ +declare namespace protocolTypes { + export type ByteArray = Buffer; + export type SignedByteArray = Buffer; + export type LittleString = string; + export type LatinString = string; + export type ShortArray = Buffer; + export type ShortString = string; + /** + * UUID is the UUID of the command called. This UUID is a bit odd as it is not specified by the server. It + * is not clear what exactly this UUID is meant to identify, but it is unique for each command called. + */ + export type uuid = string; + export type byterot = number; + export type restBuffer = Buffer; + export type encapsulated = any; + export type nbt = any; + export type lnbt = any; + export type nbtLoop = any; + export type enum_size_based_on_values_len = 'byte' | 'short' | 'int'; + export type MapInfo = any; + export type TexturePackInfos = { + uuid: string; + version: string; + size: bigint; + content_key: string; + sub_pack_name: string; + content_identity: string; + has_scripts: boolean; + addon_pack: boolean; + rtx_enabled: boolean; + /** cdn_url is a URL that the client can use to download the pack instead of the server sending it in chunks, which it will continue to do if this field is left empty. */ + cdn_url: string; + }[]; + export type ResourcePackIdVersions = { + /** The ID of the resource pack. */ + uuid: string; + /** The version of the resource pack. */ + version: string; + /** The subpack name of the resource pack. */ + name: string; + }[]; + export type ResourcePackIds = string[]; + export type Experiment = { + name: string; + enabled: boolean; + }; + export type Experiments = Experiment[]; + export type GameMode = 'survival' | 'creative' | 'adventure' | 'survival_spectator' | 'creative_spectator' | 'fallback' | 'spectator'; + export type GameRuleI32 = { + name: string; + editable: boolean; + type: 'bool' | 'int' | 'float'; + value: boolean | number; + }; + export type GameRuleVarint = { + name: string; + editable: boolean; + type: 'bool' | 'int' | 'float'; + value: boolean | number; + }; + /** + * CacheBlob represents a blob as used in the client side blob cache protocol. It holds a hash of its data and + * the full data of it. + */ + export type Blob = { + /** Hash is the hash of the blob. The hash is computed using xxHash, and must be deterministic for the same chunk data. */ + hash: bigint; + /** Payload is the data of the blob. When sent, the client will associate the Hash of the blob with the Payload in it. */ + payload: ByteArray; + }; + /** + * BlockProperties is a list of all the custom blocks registered on the server. + */ + export type BlockProperties = { + name: string; + state: any; + }[]; + export type Itemstates = { + name: string; + runtime_id: number; + component_based: boolean; + /** Version is the version of the item entry which is used by the client to determine how to handle the item entry. It is one of the constants above. */ + version: 'legacy' | 'data_driven' | 'none'; + /** Components on the item */ + nbt: any; + }[]; + export type ItemExtraDataWithBlockingTick = { + has_nbt: 'false' | 'true'; + nbt?: { + version: number; + nbt: any; + }; + can_place_on: ShortString[]; + can_destroy: ShortString[]; + blocking_tick: bigint; + }; + export type ItemExtraDataWithoutBlockingTick = { + has_nbt: 'false' | 'true'; + nbt?: { + version: number; + nbt: any; + }; + can_place_on: ShortString[]; + can_destroy: ShortString[]; + }; + /** + * Same as below but without a "networkStackID" boolean + */ + export type ItemLegacy = { + network_id: number; + count?: number; + metadata?: number; + block_runtime_id?: number; + extra?: ItemExtraDataWithBlockingTick | ItemExtraDataWithoutBlockingTick | undefined; + }; + /** + * An "ItemStack" here represents an Item instance. You can think about it like a pointer + * to an item class. The data for the class gets updated with the data in the `item` field + * As of 1.16.220, now functionally the same as `Item` just without an extra boolean when + * server auth inventories is disabled. + */ + export type Item = { + network_id: number; + count?: number; + metadata?: number; + has_stack_id?: number; + stack_id?: number | undefined; + block_runtime_id?: number; + extra?: ItemExtraDataWithBlockingTick | ItemExtraDataWithoutBlockingTick | undefined; + }; + export type vec3i = { + x: number; + y: number; + z: number; + }; + export type vec3li = { + x: number; + y: number; + z: number; + }; + export type vec3u = { + x: number; + y: number; + z: number; + }; + export type vec3f = { + x: number; + y: number; + z: number; + }; + export type vec2f = { + x: number; + z: number; + }; + export type Vec3fopts = { + x?: number; + y?: number; + z?: number; + }; + export type Vec2fopts = { + x?: number; + y?: number; + }; + export type MetadataDictionary = { + /** https://github.com/pmmp/PocketMine-MP/blob/stable/src/pocketmine/entity/Entity.php#L101 */ + key: + | 'flags' + | 'health' + | 'variant' + | 'color' + | 'nametag' + | 'owner_eid' + | 'target_eid' + | 'air' + | 'potion_color' + | 'potion_ambient' + | 'jump_duration' + | 'hurt_time' + | 'hurt_direction' + | 'paddle_time_left' + | 'paddle_time_right' + | 'experience_value' + | 'minecart_display_block' + | 'minecart_display_offset' + | 'minecart_has_display' + | 'horse_type' + | 'creeper_swell' + | 'creeper_swell_direction' + | 'charge_amount' + | 'enderman_held_runtime_id' + | 'entity_age' + | 'player_flags' + | 'player_index' + | 'player_bed_position' + | 'fireball_power_x' + | 'fireball_power_y' + | 'fireball_power_z' + | 'aux_power' + | 'fish_x' + | 'fish_z' + | 'fish_angle' + | 'potion_aux_value' + | 'lead_holder_eid' + | 'scale' + | 'interactive_tag' + | 'npc_skin_id' + | 'url_tag' + | 'max_airdata_max_air' + | 'mark_variant' + | 'container_type' + | 'container_base_size' + | 'container_extra_slots_per_strength' + | 'block_target' + | 'wither_invulnerable_ticks' + | 'wither_target_1' + | 'wither_target_2' + | 'wither_target_3' + | 'wither_aerial_attack' + | 'boundingbox_width' + | 'boundingbox_height' + | 'fuse_length' + | 'rider_seat_position' + | 'rider_rotation_locked' + | 'rider_max_rotation' + | 'rider_min_rotation' + | 'rider_seat_rotation_offset' + | 'area_effect_cloud_radius' + | 'area_effect_cloud_waiting' + | 'area_effect_cloud_particle_id' + | 'shulker_peek_id' + | 'shulker_attach_face' + | 'shulker_attached' + | 'shulker_attach_pos' + | 'trading_player_eid' + | 'trading_career' + | 'has_command_block' + | 'command_block_command' + | 'command_block_last_output' + | 'command_block_track_output' + | 'controlling_rider_seat_number' + | 'strength' + | 'max_strength' + | 'evoker_spell_casting_color' + | 'limited_life' + | 'armor_stand_pose_index' + | 'ender_crystal_time_offset' + | 'always_show_nametag' + | 'color_2' + | 'name_author' + | 'score_tag' + | 'balloon_attached_entity' + | 'pufferfish_size' + | 'bubble_time' + | 'agent' + | 'sitting_amount' + | 'sitting_amount_previous' + | 'eating_counter' + | 'flags_extended' + | 'laying_amount' + | 'laying_amount_previous' + | 'area_effect_cloud_duration' + | 'area_effect_cloud_spawn_time' + | 'area_effect_cloud_change_rate' + | 'area_effect_cloud_change_on_pickup' + | 'area_effect_cloud_pickup_count' + | 'interact_text' + | 'trade_tier' + | 'max_trade_tier' + | 'trade_experience' + | 'skin_id' + | 'spawning_frames' + | 'command_block_tick_delay' + | 'command_block_execute_on_first_tick' + | 'ambient_sound_interval' + | 'ambient_sound_interval_range' + | 'ambient_sound_event_name' + | 'fall_damage_multiplier' + | 'name_raw_text' + | 'can_ride_target' + | 'low_tier_cured_discount' + | 'high_tier_cured_discount' + | 'nearby_cured_discount' + | 'nearby_cured_discount_timestamp' + | 'hitbox' + | 'is_buoyant' + | 'freezing_effect_strength' + | 'buoyancy_data' + | 'goat_horn_count' + | 'update_properties' + | 'movement_sound_distance_offset' + | 'heartbeat_interval_ticks' + | 'heartbeat_sound_event' + | 'player_last_death_position' + | 'player_last_death_dimension' + | 'player_has_died' + | 'collision_box' + | 'visible_mob_effects' + | 'filtered_name' + | 'bed_enter_position' + | 'seat_third_person_camera_radius' + | 'seat_camera_relax_distance_smoothing'; + type: 'byte' | 'short' | 'int' | 'float' | 'string' | 'compound' | 'vec3i' | 'long' | 'vec3f'; + value: MetadataFlags1 | MetadataFlags2 | number | number | string | any | vec3i | bigint | vec3f; + }[]; + export type MetadataFlags1 = { + onfire?: boolean; + sneaking?: boolean; + riding?: boolean; + sprinting?: boolean; + action?: boolean; + invisible?: boolean; + tempted?: boolean; + inlove?: boolean; + saddled?: boolean; + powered?: boolean; + ignited?: boolean; + baby?: boolean; + converting?: boolean; + critical?: boolean; + can_show_nametag?: boolean; + always_show_nametag?: boolean; + no_ai?: boolean; + silent?: boolean; + wallclimbing?: boolean; + can_climb?: boolean; + swimmer?: boolean; + can_fly?: boolean; + walker?: boolean; + resting?: boolean; + sitting?: boolean; + angry?: boolean; + interested?: boolean; + charged?: boolean; + tamed?: boolean; + orphaned?: boolean; + leashed?: boolean; + sheared?: boolean; + gliding?: boolean; + elder?: boolean; + moving?: boolean; + breathing?: boolean; + chested?: boolean; + stackable?: boolean; + showbase?: boolean; + rearing?: boolean; + vibrating?: boolean; + idling?: boolean; + evoker_spell?: boolean; + charge_attack?: boolean; + wasd_controlled?: boolean; + can_power_jump?: boolean; + can_dash?: boolean; + linger?: boolean; + has_collision?: boolean; + affected_by_gravity?: boolean; + fire_immune?: boolean; + dancing?: boolean; + enchanted?: boolean; + show_trident_rope?: boolean; + container_private?: boolean; + transforming?: boolean; + spin_attack?: boolean; + swimming?: boolean; + bribed?: boolean; + pregnant?: boolean; + laying_egg?: boolean; + rider_can_pick?: boolean; + transition_sitting?: boolean; + eating?: boolean; + laying_down?: boolean; + }; + export type MetadataFlags2 = { + sneezing?: boolean; + trusting?: boolean; + rolling?: boolean; + scared?: boolean; + in_scaffolding?: boolean; + over_scaffolding?: boolean; + fall_through_scaffolding?: boolean; + blocking?: boolean; + transition_blocking?: boolean; + blocked_using_shield?: boolean; + blocked_using_damaged_shield?: boolean; + sleeping?: boolean; + wants_to_wake?: boolean; + trade_interest?: boolean; + door_breaker?: boolean; + breaking_obstruction?: boolean; + door_opener?: boolean; + illager_captain?: boolean; + stunned?: boolean; + roaring?: boolean; + delayed_attacking?: boolean; + avoiding_mobs?: boolean; + avoiding_block?: boolean; + facing_target_to_range_attack?: boolean; + hidden_when_invisible?: boolean; + is_in_ui?: boolean; + stalking?: boolean; + emoting?: boolean; + celebrating?: boolean; + admiring?: boolean; + celebrating_special?: boolean; + unknown95?: boolean; + ram_attack?: boolean; + playing_dead?: boolean; + in_ascendable_block?: boolean; + over_descendable_block?: boolean; + croaking?: boolean; + eat_mob?: boolean; + jump_goal_jump?: boolean; + emerging?: boolean; + sniffing?: boolean; + digging?: boolean; + sonic_boom?: boolean; + has_dash_cooldown?: boolean; + push_towards_closest_space?: boolean; + scenting?: boolean; + rising?: boolean; + feeling_happy?: boolean; + searching?: boolean; + crawling?: boolean; + timer_flag_1?: boolean; + timer_flag_2?: boolean; + timer_flag_3?: boolean; + body_rotation_blocked?: boolean; + render_when_invisible?: boolean; + body_rotation_axis_aligned?: boolean; + collidable?: boolean; + wasd_air_controlled?: boolean; + does_server_auth_only_dismount?: boolean; + body_rotation_always_follows_head?: boolean; + can_use_vertical_movement_action?: boolean; + rotation_locked_to_vehicle?: boolean; + }; + export type Link = { + ridden_entity_id: bigint; + rider_entity_id: bigint; + type: number; + immediate: boolean; + rider_initiated: boolean; + /** angular velocity of the vehicle that the rider is riding. */ + angular_velocity: number; + }; + export type Links = Link[]; + export type EntityAttributes = { + name: string; + min: number; + value: number; + max: number; + }[]; + export type EntityProperties = { + ints: { + index: number; + value: number; + }[]; + floats: { + index: number; + value: number; + }[]; + }; + export type Rotation = { + yaw: number; + pitch: number; + head_yaw: number; + }; + export type BlockCoordinates = { + x: number; + y: number; + z: number; + }; + export type PlayerAttributes = { + min: number; + max: number; + current: number; + default_min: number; + default_max: number; + default: number; + name: string; + modifiers: { + id: string; + name: string; + amount: number; + operation: number; + operand: number; + serializable: boolean; + }[]; + }[]; + /** + * UseItemTransactionData represents an inventory transaction data object sent when the client uses an item on + * a block. Also used in PlayerAuthoritativeInput packet + */ + export type TransactionUseItem = { + /** ActionType is the type of the UseItem inventory transaction. It is one of the action types found above, and specifies the way the player interacted with the block. */ + action_type: 'click_block' | 'click_air' | 'break_block' | 'attack'; + /** TriggerType is the type of the trigger that caused the inventory transaction. It is one of the trigger types found in the constants above. If TriggerType is TriggerTypePlayerInput, the transaction is from the initial input of the player. If it is TriggerTypeSimulationTick, the transaction is from a simulation tick when the player is holding down the input. */ + trigger_type: 'unknown' | 'player_input' | 'simulation_tick'; + /** BlockPosition is the position of the block that was interacted with. This is only really a correct block position if ActionType is not UseItemActionClickAir. */ + block_position: BlockCoordinates; + /** BlockFace is the face of the block that was interacted with. When clicking the block, it is the face clicked. When breaking the block, it is the face that was last being hit until the block broke. */ + face: number; + /** HotBarSlot is the hot bar slot that the player was holding while clicking the block. It should be used to ensure that the hot bar slot and held item are correctly synchronised with the server. */ + hotbar_slot: number; + /** HeldItem is the item that was held to interact with the block. The server should check if this item is actually present in the HotBarSlot. */ + held_item: Item; + /** Position is the position of the player at the time of interaction. For clicking a block, this is the position at that time, whereas for breaking the block it is the position at the time of breaking. */ + player_pos: vec3f; + /** ClickedPosition is the position that was clicked relative to the block's base coordinate. It can be used to find out exactly where a player clicked the block. */ + click_pos: vec3f; + /** BlockRuntimeID is the runtime ID of the block that was clicked. It may be used by the server to verify that the player's world client-side is synchronised with the server's. */ + block_runtime_id: number; + /** ClientPrediction is the client's prediction on the output of the transaction. */ + client_prediction: 'failure' | 'success'; + }; + /** + * Actions is a list of actions that took place, that form the inventory transaction together. Each of + * these actions hold one slot in which one item was changed to another. In general, the combination of + * all of these actions results in a balanced inventory transaction. This should be checked to ensure that + * no items are cheated into the inventory. + */ + export type TransactionActions = { + source_type: 'container' | 'global' | 'world_interaction' | 'creative' | 'craft_slot' | 'craft'; + inventory_id?: WindowIDVarint; + action?: number; + flags?: number; + slot: number; + old_item: Item; + new_item: Item; + }[]; + /** + * The Minecraft bedrock inventory system was refactored, but not all inventory actions use the new packet. + * This data structure holds actions that have not been updated to the new system. + */ + export type TransactionLegacy = { + /** LegacyRequestID is an ID that is only non-zero at times when sent by the client. The server should always send 0 for this. When this field is not 0, the LegacySetItemSlots slice below will have values in it. LegacyRequestID ties in with the ItemStackResponse packet. If this field is non-0, the server should respond with an ItemStackResponse packet. Some inventory actions such as dropping an item out of the hotbar are still one using this packet, and the ItemStackResponse packet needs to tie in with it. */ + legacy_request_id: number; + /** `legacy_transactions` are only present if the LegacyRequestID is non-zero. These item slots inform the server of the slots that were changed during the inventory transaction, and the server should send back an ItemStackResponse packet with these slots present in it. (Or false with no slots, if rejected.) */ + legacy_transactions?: { + container_id: number; + changed_slots: { + slot_id: number; + }[]; + }[]; + }; + export type Transaction = { + /** Old transaction system data */ + legacy: TransactionLegacy; + /** What type of transaction took place */ + transaction_type: 'normal' | 'inventory_mismatch' | 'item_use' | 'item_use_on_entity' | 'item_release'; + /** The list of inventory internal actions in this packet, e.g. inventory GUI actions */ + actions: TransactionActions; + /** Extra data if an intenal inventory transaction did not take place, e.g. use of an item */ + transaction_data: + | TransactionUseItem + | { + entity_runtime_id: bigint; + action_type: 'interact' | 'attack'; + hotbar_slot: number; + held_item: Item; + player_pos: vec3f; + click_pos: vec3f; + } + | { + action_type: 'release' | 'consume'; + hotbar_slot: number; + held_item: Item; + head_pos: vec3f; + }; + }; + export type ItemStacks = Item[]; + export type RecipeIngredient = { + type: 'invalid' | 'int_id_meta' | 'molang' | 'item_tag' | 'string_id_meta' | 'complex_alias'; + network_id?: number; + metadata?: number | undefined | number; + expression?: string; + version?: number; + tag?: string; + name?: string; + count: number; + }; + export type PotionTypeRecipes = { + input_item_id: number; + input_item_meta: number; + ingredient_id: number; + ingredient_meta: number; + output_item_id: number; + output_item_meta: number; + }[]; + export type PotionContainerChangeRecipes = { + input_item_id: number; + ingredient_id: number; + output_item_id: number; + }[]; + export type Recipes = { + type: 'shapeless' | 'shaped' | 'furnace' | 'furnace_with_metadata' | 'multi' | 'shulker_box' | 'shapeless_chemistry' | 'shaped_chemistry' | 'smithing_transform' | 'smithing_trim'; + recipe: + | { + recipe_id: LatinString; + input: RecipeIngredient[]; + output: ItemLegacy[]; + uuid: string; + block: string; + priority: number; + unlocking_requirement: RecipeUnlockingRequirement; + network_id: number; + } + | { + recipe_id: LatinString; + width: number; + height: number; + input: RecipeIngredient[][]; + output: ItemLegacy[]; + uuid: string; + block: string; + priority: number; + assume_symmetry: boolean; + unlocking_requirement: RecipeUnlockingRequirement; + network_id: number; + } + | { + input_id: number; + output: ItemLegacy; + block: string; + } + | { + input_id: number; + input_meta: number; + output: ItemLegacy; + block: string; + } + | { + uuid: string; + network_id: number; + } + | { + recipe_id: LatinString; + template: RecipeIngredient; + base: RecipeIngredient; + addition: RecipeIngredient; + result: ItemLegacy; + tag: string; + network_id: number; + } + | { + recipe_id: LatinString; + template: RecipeIngredient; + input: RecipeIngredient; + addition: RecipeIngredient; + block: string; + network_id: number; + }; + }[]; + export type RecipeUnlockingRequirement = { + context: 'none' | 'always_unlocked' | 'player_in_water' | 'player_has_many_items'; + ingredients?: RecipeIngredient[]; + }; + export type SkinImage = { + width: number; + height: number; + data: ByteArray; + }; + export type Skin = { + skin_id: string; + play_fab_id: string; + skin_resource_pack: string; + skin_data: SkinImage; + animations: { + skin_image: SkinImage; + animation_type: number; + animation_frames: number; + expression_type: number; + }[]; + cape_data: SkinImage; + geometry_data: string; + geometry_data_version: string; + animation_data: string; + cape_id: string; + full_skin_id: string; + arm_size: string; + skin_color: string; + personal_pieces: { + piece_id: string; + piece_type: string; + pack_id: string; + is_default_piece: boolean; + product_id: string; + }[]; + piece_tint_colors: { + piece_type: string; + colors: string[]; + }[]; + premium: boolean; + persona: boolean; + cape_on_classic: boolean; + primary_user: boolean; + overriding_player_appearance: boolean; + }; + export type PlayerRecords = { + type: 'add' | 'remove'; + records_count: number; + records: + | { + uuid: string; + entity_unique_id: bigint; + username: string; + xbox_user_id: string; + platform_chat_id: string; + build_platform: number; + skin_data: Skin; + is_teacher: boolean; + is_host: boolean; + is_subclient: boolean; + player_color: number; + } + | { + uuid: string; + }[]; + verified?: boolean[]; + }; + export type Enchant = { + id: number; + level: number; + }; + export type EnchantOption = { + cost: number; + slot_flags: number; + equip_enchants: Enchant[]; + held_enchants: Enchant[]; + self_enchants: Enchant[]; + name: string; + option_id: number; + }; + export type Action = + | 'start_break' + | 'abort_break' + | 'stop_break' + | 'get_updated_block' + | 'drop_item' + | 'start_sleeping' + | 'stop_sleeping' + | 'respawn' + | 'jump' + | 'start_sprint' + | 'stop_sprint' + | 'start_sneak' + | 'stop_sneak' + | 'creative_player_destroy_block' + | 'dimension_change_ack' + | 'start_glide' + | 'stop_glide' + | 'build_denied' + | 'crack_break' + | 'change_skin' + | 'set_enchatnment_seed' + | 'swimming' + | 'stop_swimming' + | 'start_spin_attack' + | 'stop_spin_attack' + | 'interact_block' + | 'predict_break' + | 'continue_break' + | 'start_item_use_on' + | 'stop_item_use_on' + | 'handled_teleport' + | 'missed_swing' + | 'start_crawling' + | 'stop_crawling' + | 'start_flying' + | 'stop_flying' + | 'received_server_data' + | 'start_using_item'; + /** + * Source and Destination point to the source slot from which Count of the item stack were taken and the + * destination slot to which this item was moved. + */ + export type StackRequestSlotInfo = { + /** ContainerID is the ID of the container that the slot was in. */ + slot_type: FullContainerName; + /** Slot is the index of the slot within the container with the ContainerID above. */ + slot: number; + /** StackNetworkID is the unique stack ID that the client assumes to be present in this slot. The server must check if these IDs match. If they do not match, servers should reject the stack request that the action holding this info was in. */ + stack_id: number; + }; + /** + * ItemStackRequest is sent by the client to change item stacks in an inventory. It is essentially a + * replacement of the InventoryTransaction packet added in 1.16 for inventory specific actions, such as moving + * items around or crafting. The InventoryTransaction packet is still used for actions such as placing blocks + * and interacting with entities. + */ + export type ItemStackRequest = { + /** RequestID is a unique ID for the request. This ID is used by the server to send a response for this specific request in the ItemStackResponse packet. */ + request_id: number; + actions: { + type_id: + | 'take' + | 'place' + | 'swap' + | 'drop' + | 'destroy' + | 'consume' + | 'create' + | 'place_in_container' + | 'take_out_container' + | 'lab_table_combine' + | 'beacon_payment' + | 'mine_block' + | 'craft_recipe' + | 'craft_recipe_auto' + | 'craft_creative' + | 'optional' + | 'craft_grindstone_request' + | 'craft_loom_request' + | 'non_implemented' + | 'results_deprecated'; + count?: number; + source?: StackRequestSlotInfo; + destination?: StackRequestSlotInfo; + randomly?: boolean; + result_slot_id?: number; + primary_effect?: number; + secondary_effect?: number; + hotbar_slot?: number; + predicted_durability?: number; + network_id?: number; + recipe_network_id?: number; + times_crafted?: number; + times_crafted_2?: number; + ingredients?: RecipeIngredient[]; + item_id?: number; + filtered_string_index?: number; + cost?: number; + pattern?: string; + result_items?: ItemLegacy[]; + }[]; + custom_names: string[]; + cause: + | 'chat_public' + | 'chat_whisper' + | 'sign_text' + | 'anvil_text' + | 'book_and_quill_text' + | 'command_block_text' + | 'block_actor_data_text' + | 'join_event_text' + | 'leave_event_text' + | 'slash_command_chat' + | 'cartography_text' + | 'kick_command' + | 'title_command' + | 'summon_command'; + }; + /** + * ItemStackResponse is a response to an individual ItemStackRequest. + */ + export type ItemStackResponses = { + /** Status specifies if the request with the RequestID below was successful. If this is the case, the ContainerInfo below will have information on what slots ended up changing. If not, the container info will be empty. A non-0 status means an error occurred and will result in the action being reverted. */ + status: 'ok' | 'error'; + /** RequestID is the unique ID of the request that this response is in reaction to. If rejected, the client will undo the actions from the request with this ID. */ + request_id: number; + containers?: { + slot_type: FullContainerName; + slots: { + slot: number; + hotbar_slot: number; + count: number; + item_stack_id: number; + custom_name: string; + filtered_custom_name: string; + durability_correction: number; + }[]; + }[]; + }[]; + export type CommandOrigin = { + /** Origin is one of the values above that specifies the origin of the command. The origin may change, depending on what part of the client actually called the command. The command may be issued by a websocket server, for example. */ + type: string; + uuid: string; + request_id: string; + player_entity_id: bigint; + }; + /** + * MapTrackedObject is an object on a map that is 'tracked' by the client, such as an entity or a block. This + * object may move, which is handled client-side. + */ + export type TrackedObject = { + /** Type is the type of the tracked object. It is either MapObjectTypeEntity or MapObjectTypeBlock. */ + type: 'entity' | 'block'; + /** EntityUniqueID is the unique ID of the entity, if the tracked object was an entity. It needs not to be filled out if Type is not MapObjectTypeEntity. */ + entity_unique_id?: bigint; + /** BlockPosition is the position of the block, if the tracked object was a block. It needs not to be filled out if Type is not MapObjectTypeBlock. */ + block_position?: BlockCoordinates; + }; + /** + * MapDecoration is a fixed decoration on a map: Its position or other properties do not change automatically + * client-side. + */ + export type MapDecoration = { + type: + | 'marker_white' + | 'marker_green' + | 'marker_red' + | 'marker_blue' + | 'cross_white' + | 'triangle_red' + | 'square_white' + | 'marker_sign' + | 'marker_pink' + | 'marker_orange' + | 'marker_yellow' + | 'marker_teal' + | 'triangle_green' + | 'small_square_white' + | 'mansion' + | 'monument' + | 'no_draw' + | 'village_desert' + | 'village_plains' + | 'village_savanna' + | 'village_snowy' + | 'village_taiga' + | 'jungle_temple' + | 'witch_hut =>' + | 'witch_hut'; + /** Rotation is the rotation of the map decoration. It is byte due to the 16 fixed directions that the map decoration may face. */ + rotation: number; + /** X is the offset on the X axis in pixels of the decoration. */ + x: number; + /** Y is the offset on the Y axis in pixels of the decoration. */ + y: number; + /** Label is the name of the map decoration. This name may be of any value. */ + label: string; + /** Colour is the colour of the map decoration. Some map decoration types have a specific colour set automatically, whereas others may be changed. */ + color_abgr: number; + }; + export type StructureBlockSettings = { + /** PaletteName is the name of the palette used in the structure. Currently, it seems that this field is always 'default'. */ + palette_name: string; + /** IgnoreEntities specifies if the structure should ignore entities or include them. If set to false, entities will also show up in the exported structure. */ + ignore_entities: boolean; + /** IgnoreBlocks specifies if the structure should ignore blocks or include them. If set to false, blocks will show up in the exported structure. */ + ignore_blocks: boolean; + non_ticking_players_and_ticking_areas: boolean; + /** Size is the size of the area that is about to be exported. The area exported will start at the Position + Offset, and will extend as far as Size specifies. */ + size: BlockCoordinates; + /** Offset is the offset position that was set in the structure block. The area exported is offset by this position. **TODO**: This will be renamed to offset soon */ + structure_offset: BlockCoordinates; + /** LastEditingPlayerUniqueID is the unique ID of the player that last edited the structure block that these settings concern. */ + last_editing_player_unique_id: bigint; + /** Rotation is the rotation that the structure block should obtain. See the constants above for available options. */ + rotation: 'none' | '90_deg' | '180_deg' | '270_deg'; + /** Mirror specifies the way the structure should be mirrored. It is either no mirror at all, mirror on the x/z axis or both. */ + mirror: 'none' | 'x_axis' | 'z_axis' | 'both_axes'; + animation_mode: 'none' | 'layers' | 'blocks'; + /** How long the duration for this animation is */ + animation_duration: number; + /** Integrity is usually 1, but may be set to a number between 0 and 1 to omit blocks randomly, using the Seed that follows. */ + integrity: number; + /** Seed is the seed used to omit blocks if Integrity is not equal to one. If the Seed is 0, a random seed is selected to omit blocks. */ + seed: number; + /** Pivot is the pivot around which the structure may be rotated. */ + pivot: vec3f; + }; + /** + * EducationSharedResourceURI is an education edition feature that is used for transmitting + * education resource settings to clients. It contains a button name and a link URL. + */ + export type EducationSharedResourceURI = { + /** ButtonName is the button name of the resource URI. */ + button_name: string; + /** LinkURI is the link URI for the resource URI. */ + link_uri: string; + }; + export type EducationExternalLinkSettings = { + /** URL is the external link URL. */ + url: string; + /** DisplayName is the display name in game. */ + display_name: string; + }; + export type BlockUpdate = { + position: BlockCoordinates; + runtime_id: number; + flags: number; + /** EntityUniqueID is the unique ID of the falling block entity that the block transitions to or that the entity transitions from. Note that for both possible values for TransitionType, the EntityUniqueID should point to the falling block entity involved. */ + entity_unique_id: bigint; + /** TransitionType is the type of the transition that happened. It is either BlockToEntityTransition, when a block placed becomes a falling entity, or EntityToBlockTransition, when a falling entity hits the ground and becomes a solid block again. */ + transition_type: 'entity' | 'create' | 'destroy'; + }; + export type MaterialReducer = { + mix: number; + items: { + network_id: number; + count: number; + }; + }; + /** + * # Permissions + * The permission level of a player, for example whether they are an Server Operator or not. + */ + export type PermissionLevel = 'visitor' | 'member' | 'operator' | 'custom'; + /** + * The command permission level, for example if being run by a Player, an Op, or a Command Block. + */ + export type CommandPermissionLevel = 'normal' | 'operator' | 'automation' | 'host' | 'owner' | 'internal'; + /** + * The command permission level, for example if being run by a Player, an Op, or a Command Block. + */ + export type CommandPermissionLevelVarint = 'normal' | 'operator' | 'automation' | 'host' | 'owner' | 'internal'; + /** + * List of Window IDs. When a new container is opened (container_open), a new sequential Window ID is created. + * Below window IDs are hard-coded and created when the game starts and the server does not + * send a `container_open` for them. + */ + export type WindowID = + | 'inventory' + | 'first' + | 'last' + | 'offhand' + | 'armor' + | 'creative' + | 'hotbar' + | 'fixed_inventory' + | 'ui' + | 'drop_contents' + | 'beacon' + | 'trading_output' + | 'trading_use_inputs' + | 'trading_input_2' + | 'trading_input_1' + | 'enchant_output' + | 'enchant_material' + | 'enchant_input' + | 'anvil_output' + | 'anvil_result' + | 'anvil_material' + | 'container_input' + | 'crafting_use_ingredient' + | 'crafting_result' + | 'crafting_remove_ingredient' + | 'crafting_add_ingredient' + | 'none'; + export type WindowIDVarint = + | 'inventory' + | 'first' + | 'last' + | 'offhand' + | 'armor' + | 'creative' + | 'hotbar' + | 'fixed_inventory' + | 'ui' + | 'drop_contents' + | 'beacon' + | 'trading_output' + | 'trading_use_inputs' + | 'trading_input_2' + | 'trading_input_1' + | 'enchant_output' + | 'enchant_material' + | 'enchant_input' + | 'anvil_output' + | 'anvil_result' + | 'anvil_material' + | 'container_input' + | 'crafting_use_ingredient' + | 'crafting_result' + | 'crafting_remove_ingredient' + | 'crafting_add_ingredient' + | 'none'; + export type WindowType = + | 'container' + | 'workbench' + | 'furnace' + | 'enchantment' + | 'brewing_stand' + | 'anvil' + | 'dispenser' + | 'dropper' + | 'hopper' + | 'cauldron' + | 'minecart_chest' + | 'minecart_hopper' + | 'horse' + | 'beacon' + | 'structure_editor' + | 'trading' + | 'command_block' + | 'jukebox' + | 'armor' + | 'hand' + | 'compound_creator' + | 'element_constructor' + | 'material_reducer' + | 'lab_table' + | 'loom' + | 'lectern' + | 'grindstone' + | 'blast_furnace' + | 'smoker' + | 'stonecutter' + | 'cartography' + | 'hud' + | 'jigsaw_editor' + | 'smithing_table' + | 'chest_boat' + | 'decorated_pot' + | 'crafter' + | 'none' + | 'inventory'; + /** + * Used in inventory transactions. + */ + export type ContainerSlotType = + | 'anvil_input' + | 'anvil_material' + | 'anvil_result' + | 'smithing_table_input' + | 'smithing_table_material' + | 'smithing_table_result' + | 'armor' + | 'container' + | 'beacon_payment' + | 'brewing_input' + | 'brewing_result' + | 'brewing_fuel' + | 'hotbar_and_inventory' + | 'crafting_input' + | 'crafting_output' + | 'recipe_construction' + | 'recipe_nature' + | 'recipe_items' + | 'recipe_search' + | 'recipe_search_bar' + | 'recipe_equipment' + | 'recipe_book' + | 'enchanting_input' + | 'enchanting_lapis' + | 'furnace_fuel' + | 'furnace_ingredient' + | 'furnace_output' + | 'horse_equip' + | 'hotbar' + | 'inventory' + | 'shulker' + | 'trade_ingredient1' + | 'trade_ingredient2' + | 'trade_result' + | 'offhand' + | 'compcreate_input' + | 'compcreate_output' + | 'elemconstruct_output' + | 'matreduce_input' + | 'matreduce_output' + | 'labtable_input' + | 'loom_input' + | 'loom_dye' + | 'loom_material' + | 'loom_result' + | 'blast_furnace_ingredient' + | 'smoker_ingredient' + | 'trade2_ingredient1' + | 'trade2_ingredient2' + | 'trade2_result' + | 'grindstone_input' + | 'grindstone_additional' + | 'grindstone_result' + | 'stonecutter_input' + | 'stonecutter_result' + | 'cartography_input' + | 'cartography_additional' + | 'cartography_result' + | 'barrel' + | 'cursor' + | 'creative_output' + | 'smithing_table_template' + | 'crafter' + | 'dynamic' + | 'registry'; + export type SoundType = + | 'ItemUseOn' + | 'Hit' + | 'Step' + | 'Fly' + | 'Jump' + | 'Break' + | 'Place' + | 'HeavyStep' + | 'Gallop' + | 'Fall' + | 'Ambient' + | 'AmbientBaby' + | 'AmbientInWater' + | 'Breathe' + | 'Death' + | 'DeathInWater' + | 'DeathToZombie' + | 'Hurt' + | 'HurtInWater' + | 'Mad' + | 'Boost' + | 'Bow' + | 'SquishBig' + | 'SquishSmall' + | 'FallBig' + | 'FallSmall' + | 'Splash' + | 'Fizz' + | 'Flap' + | 'Swim' + | 'Drink' + | 'Eat' + | 'Takeoff' + | 'Shake' + | 'Plop' + | 'Land' + | 'Saddle' + | 'Armor' + | 'ArmorStandPlace' + | 'AddChest' + | 'Throw' + | 'Attack' + | 'AttackNoDamage' + | 'AttackStrong' + | 'Warn' + | 'Shear' + | 'Milk' + | 'Thunder' + | 'Explode' + | 'Fire' + | 'Ignite' + | 'Fuse' + | 'Stare' + | 'Spawn' + | 'Shoot' + | 'BreakBlock' + | 'Launch' + | 'Blast' + | 'LargeBlast' + | 'Twinkle' + | 'Remedy' + | 'Unfect' + | 'LevelUp' + | 'BowHit' + | 'BulletHit' + | 'ExtinguishFire' + | 'ItemFizz' + | 'ChestOpen' + | 'ChestClosed' + | 'ShulkerBoxOpen' + | 'ShulkerBoxClosed' + | 'EnderChestOpen' + | 'EnderChestClosed' + | 'PowerOn' + | 'PowerOff' + | 'Attach' + | 'Detach' + | 'Deny' + | 'Tripod' + | 'Pop' + | 'DropSlot' + | 'Note' + | 'Thorns' + | 'PistonIn' + | 'PistonOut' + | 'Portal' + | 'Water' + | 'LavaPop' + | 'Lava' + | 'Burp' + | 'BucketFillWater' + | 'BucketFillLava' + | 'BucketEmptyWater' + | 'BucketEmptyLava' + | 'ArmorEquipChain' + | 'ArmorEquipDiamond' + | 'ArmorEquipGeneric' + | 'ArmorEquipGold' + | 'ArmorEquipIron' + | 'ArmorEquipLeather' + | 'ArmorEquipElytra' + | 'Record13' + | 'RecordCat' + | 'RecordBlocks' + | 'RecordChirp' + | 'RecordFar' + | 'RecordMall' + | 'RecordMellohi' + | 'RecordStal' + | 'RecordStrad' + | 'RecordWard' + | 'Record11' + | 'RecordWait' + | 'StopRecord' + | 'Flop' + | 'GuardianCurse' + | 'MobWarning' + | 'MobWarningBaby' + | 'Teleport' + | 'ShulkerOpen' + | 'ShulkerClose' + | 'Haggle' + | 'HaggleYes' + | 'HaggleNo' + | 'HaggleIdle' + | 'ChorusGrow' + | 'ChorusDeath' + | 'Glass' + | 'PotionBrewed' + | 'CastSpell' + | 'PrepareAttackSpell' + | 'PrepareSummon' + | 'PrepareWololo' + | 'Fang' + | 'Charge' + | 'CameraTakePicture' + | 'LeashKnotPlace' + | 'LeashKnotBreak' + | 'AmbientGrowl' + | 'AmbientWhine' + | 'AmbientPant' + | 'AmbientPurr' + | 'AmbientPurreow' + | 'DeathMinVolume' + | 'DeathMidVolume' + | 'ImitateBlaze' + | 'ImitateCaveSpider' + | 'ImitateCreeper' + | 'ImitateElderGuardian' + | 'ImitateEnderDragon' + | 'ImitateEnderman' + | 'ImitateEndermite' + | 'ImitateEvocationIllager' + | 'ImitateGhast' + | 'ImitateHusk' + | 'ImitateIllusionIllager' + | 'ImitateMagmaCube' + | 'ImitatePolarBear' + | 'ImitateShulker' + | 'ImitateSilverfish' + | 'ImitateSkeleton' + | 'ImitateSlime' + | 'ImitateSpider' + | 'ImitateStray' + | 'ImitateVex' + | 'ImitateVindicationIllager' + | 'ImitateWitch' + | 'ImitateWither' + | 'ImitateWitherSkeleton' + | 'ImitateWolf' + | 'ImitateZombie' + | 'ImitateZombiePigman' + | 'ImitateZombieVillager' + | 'EnderEyePlaced' + | 'EndPortalCreated' + | 'AnvilUse' + | 'BottleDragonBreath' + | 'PortalTravel' + | 'TridentHit' + | 'TridentReturn' + | 'TridentRiptide1' + | 'TridentRiptide2' + | 'TridentRiptide3' + | 'TridentThrow' + | 'TridentThunder' + | 'TridentHitGround' + | 'Default' + | 'FletchingTableUse' + | 'ElemConstructOpen' + | 'IceBombHit' + | 'BalloonPop' + | 'LtReactionIceBomb' + | 'LtReactionBleach' + | 'LtReactionElephantToothpaste' + | 'LtReactionElephantToothpaste2' + | 'LtReactionGlowStick' + | 'LtReactionGlowStick2' + | 'LtReactionLuminol' + | 'LtReactionSalt' + | 'LtReactionFertilizer' + | 'LtReactionFireball' + | 'LtReactionMagnesiumSalt' + | 'LtReactionMiscFire' + | 'LtReactionFire' + | 'LtReactionMiscExplosion' + | 'LtReactionMiscMystical' + | 'LtReactionMiscMystical2' + | 'LtReactionProduct' + | 'SparklerUse' + | 'GlowStickUse' + | 'SparklerActive' + | 'ConvertToDrowned' + | 'BucketFillFish' + | 'BucketEmptyFish' + | 'BubbleColumnUpwards' + | 'BubbleColumnDownwards' + | 'BubblePop' + | 'BubbleUpInside' + | 'BubbleDownInside' + | 'HurtBaby' + | 'DeathBaby' + | 'StepBaby' + | 'SpawnBaby' + | 'Born' + | 'TurtleEggBreak' + | 'TurtleEggCrack' + | 'TurtleEggHatched' + | 'LayEgg' + | 'TurtleEggAttacked' + | 'BeaconActivate' + | 'BeaconAmbient' + | 'BeaconDeactivate' + | 'BeaconPower' + | 'ConduitActivate' + | 'ConduitAmbient' + | 'ConduitAttack' + | 'ConduitDeactivate' + | 'ConduitShort' + | 'Swoop' + | 'BambooSaplingPlace' + | 'PreSneeze' + | 'Sneeze' + | 'AmbientTame' + | 'Scared' + | 'ScaffoldingClimb' + | 'CrossbowLoadingStart' + | 'CrossbowLoadingMiddle' + | 'CrossbowLoadingEnd' + | 'CrossbowShoot' + | 'CrossbowQuickChargeStart' + | 'CrossbowQuickChargeMiddle' + | 'CrossbowQuickChargeEnd' + | 'AmbientAggressive' + | 'AmbientWorried' + | 'CantBreed' + | 'ShieldBlock' + | 'LecternBookPlace' + | 'GrindstoneUse' + | 'Bell' + | 'CampfireCrackle' + | 'Roar' + | 'Stun' + | 'SweetBerryBushHurt' + | 'SweetBerryBushPick' + | 'CartographyTableUse' + | 'StonecutterUse' + | 'ComposterEmpty' + | 'ComposterFill' + | 'ComposterFillLayer' + | 'ComposterReady' + | 'BarrelOpen' + | 'BarrelClose' + | 'RaidHorn' + | 'LoomUse' + | 'AmbientInRaid' + | 'UicartographyTableUse' + | 'UistonecutterUse' + | 'UiloomUse' + | 'SmokerUse' + | 'BlastFurnaceUse' + | 'SmithingTableUse' + | 'Screech' + | 'Sleep' + | 'FurnaceUse' + | 'MooshroomConvert' + | 'MilkSuspiciously' + | 'Celebrate' + | 'JumpPrevent' + | 'AmbientPollinate' + | 'BeehiveDrip' + | 'BeehiveEnter' + | 'BeehiveExit' + | 'BeehiveWork' + | 'BeehiveShear' + | 'HoneybottleDrink' + | 'AmbientCave' + | 'Retreat' + | 'ConvertToZombified' + | 'Admire' + | 'StepLava' + | 'Tempt' + | 'Panic' + | 'Angry' + | 'AmbientMoodWarpedForest' + | 'AmbientMoodSoulsandValley' + | 'AmbientMoodNetherWastes' + | 'AmbientMoodBasaltDeltas' + | 'AmbientMoodCrimsonForest' + | 'RespawnAnchorCharge' + | 'RespawnAnchorDeplete' + | 'RespawnAnchorSetSpawn' + | 'RespawnAnchorAmbient' + | 'SoulEscapeQuiet' + | 'SoulEscapeLoud' + | 'RecordPigstep' + | 'LinkCompassToLodestone' + | 'UseSmithingTable' + | 'EquipNetherite' + | 'AmbientLoopWarpedForest' + | 'AmbientLoopSoulsandValley' + | 'AmbientLoopNetherWastes' + | 'AmbientLoopBasaltDeltas' + | 'AmbientLoopCrimsonForest' + | 'AmbientAdditionWarpedForest' + | 'AmbientAdditionSoulsandValley' + | 'AmbientAdditionNetherWastes' + | 'AmbientAdditionBasaltDeltas' + | 'AmbientAdditionCrimsonForest' + | 'SculkSensorPowerOn' + | 'SculkSensorPowerOff' + | 'BucketFillPowderSnow' + | 'BucketEmptyPowderSnow' + | 'PointedDripstoneCauldronDripWater' + | 'PointedDripstoneCauldronDripLava' + | 'PointedDripstoneDripWater' + | 'PointedDripstoneDripLava' + | 'CaveVinesPickBerries' + | 'BigDripleafTiltDown' + | 'BigDripleafTiltUp' + | 'CopperWaxOn' + | 'CopperWaxOff' + | 'Scrape' + | 'PlayerHurtDrown' + | 'PlayerHurtOnFire' + | 'PlayerHurtFreeze' + | 'UseSpyglass' + | 'StopUsingSpyglass' + | 'AmethystBlockChime' + | 'AmbientScreamer' + | 'HurtScreamer' + | 'DeathScreamer' + | 'MilkScreamer' + | 'JumpToBlock' + | 'PreRam' + | 'PreRamScreamer' + | 'RamImpact' + | 'RamImpactScreamer' + | 'SquidInkSquirt' + | 'GlowSquidInkSquirt' + | 'ConvertToStray' + | 'CakeAddCandle' + | 'ExtinguishCandle' + | 'AmbientCandle' + | 'BlockClick' + | 'BlockClickFail' + | 'SculkCatalystBloom' + | 'SculkShriekerShriek' + | 'WardenNearbyClose' + | 'WardenNearbyCloser' + | 'WardenNearbyClosest' + | 'WardenSlightlyAngry' + | 'RecordOtherside' + | 'Tongue' + | 'CrackIronGolem' + | 'RepairIronGolem' + | 'Listening' + | 'Heartbeat' + | 'HornBreak' + | '_' + | 'SculkSpread' + | 'SculkCharge' + | 'SculkSensorPlace' + | 'SculkShriekerPlace' + | 'GoatCall0' + | 'GoatCall1' + | 'GoatCall2' + | 'GoatCall3' + | 'GoatCall4' + | 'GoatCall5' + | 'GoatCall6' + | 'GoatCall7' + | 'GoatCall8' + | 'GoatCall9' + | 'GoatHarmony0' + | 'GoatHarmony1' + | 'GoatHarmony2' + | 'GoatHarmony3' + | 'GoatHarmony4' + | 'GoatHarmony5' + | 'GoatHarmony6' + | 'GoatHarmony7' + | 'GoatHarmony8' + | 'GoatHarmony9' + | 'GoatMelody0' + | 'GoatMelody1' + | 'GoatMelody2' + | 'GoatMelody3' + | 'GoatMelody4' + | 'GoatMelody5' + | 'GoatMelody6' + | 'GoatMelody7' + | 'GoatMelody8' + | 'GoatMelody9' + | 'GoatBass0' + | 'GoatBass1' + | 'GoatBass2' + | 'GoatBass3' + | 'GoatBass4' + | 'GoatBass5' + | 'GoatBass6' + | 'GoatBass7' + | 'GoatBass8' + | 'GoatBass9' + | 'ImitateWarden' + | 'ListeningAngry' + | 'ItemGiven' + | 'ItemTaken' + | 'Disappeared' + | 'Reappeared' + | 'DrinkMilk' + | 'FrogspawnHatched' + | 'LaySpawn' + | 'FrogspawnBreak' + | 'SonicBoom' + | 'SonicCharge' + | 'SoundeventItemThrown' + | 'Record5' + | 'ConvertToFrog' + | 'RecordPlaying' + | 'EnchantingTableUse' + | 'StepSand' + | 'DashReady' + | 'BundleDropContents' + | 'BundleInsert' + | 'BundleRemoveOne' + | 'PressurePlateClickOff' + | 'PressurePlateClickOn' + | 'ButtonClickOff' + | 'ButtonClickOn' + | 'DoorOpen' + | 'DoorClose' + | 'TrapdoorOpen' + | 'TrapdoorClose' + | 'FenceGateOpen' + | 'FenceGateClose' + | 'Insert' + | 'Pickup' + | 'InsertEnchanted' + | 'PickupEnchanted' + | 'Brush' + | 'BrushCompleted' + | 'ShatterDecoratedPot' + | 'BreakDecoratedPot' + | 'SnifferEggCrack' + | 'SnifferEggHatched' + | 'WaxedSignInteractFail' + | 'RecordRelic' + | 'Bump' + | 'PumpkinCarve' + | 'ConvertHuskToZombie' + | 'PigDeath' + | 'HoglinZombified' + | 'AmbientUnderwaterEnter' + | 'AmbientUnderwaterExit' + | 'BottleFill' + | 'BottleEmpty' + | 'CrafterCraft' + | 'CrafterFail' + | 'DecoratedPotInsert' + | 'DecoratedPotInsertFail' + | 'CrafterDisableSlot' + | 'TrialSpawnerOpenShutter' + | 'TrialSpawnerEjectItem' + | 'TrialSpawnerDetectPlayer' + | 'TrialSpawnerSpawnMob' + | 'TrialSpawnerCloseShutter' + | 'TrialSpawnerAmbient' + | 'CopperBulbTurnOn' + | 'CopperBulbTurnOff' + | 'AmbientInAir' + | 'BreezeWindChargeBurst' + | 'ImitateBreeze' + | 'ArmadilloBrush' + | 'ArmadilloScuteDrop' + | 'EquipWolf' + | 'UnequipWolf' + | 'Reflect' + | 'VaultOpenShutter' + | 'VaultCloseShutter' + | 'VaultEjectItem' + | 'VaultInsertItem' + | 'VaultInsertItemFail' + | 'VaultAmbient' + | 'VaultActivate' + | 'VaultDeactive' + | 'HurtReduced' + | 'WindChargeBurst' + | 'ImitateBogged' + | 'WolfArmourCrack' + | 'WolfArmourBreak' + | 'WolfArmourRepair' + | 'MaceSmashAir' + | 'MaceSmashGround' + | 'TrialSpawnerChargeActivate' + | 'TrialSpawnerAmbientOminous' + | 'OminiousItemSpawnerSpawnItem' + | 'OminousBottleEndUse' + | 'MaceHeavySmashGround' + | 'OminousItemSpawnerSpawnItemBegin' + | 'ApplyEffectBadOmen' + | 'ApplyEffectRaidOmen' + | 'ApplyEffectTrialOmen' + | 'OminousItemSpawnerAboutToSpawnItem' + | 'RecordCreator' + | 'RecordCreatorMusicBox' + | 'RecordPrecipice' + | 'VaultRejectRewardedPlayer' + | 'ImitateDrowned' + | 'ImitateCreaking' + | 'BundleInsertFailed' + | 'SpongeAbsorb' + | 'BlockCreakingHeartTrail' + | 'CreakingHeartSpawn' + | 'Activate' + | 'Deactivate' + | 'Freeze' + | 'Unfreeze' + | 'Open' + | 'OpenLong' + | 'Close' + | 'CloseLong' + | 'ImitatePhantom' + | 'ImitateZoglin' + | 'ImitateGuardian' + | 'ImitateRavager' + | 'ImitatePillager' + | 'PlaceInWater' + | 'StateChange' + | 'ImitateHappyGhast' + | 'UniqueGeneric' + | 'RecordTears' + | 'TheEndLightFlash' + | 'LeadLeash' + | 'LeadUnleash' + | 'LeadBreak' + | 'Unsaddle' + | 'EquipCopper' + | 'RecordLavaChicken' + | 'PlaceItem' + | 'SingleItemSwap' + | 'MultiItemSwap' + | 'ItemEnchantLunge1' + | 'ItemEnchantLunge2' + | 'ItemEnchantLunge3' + | 'AttackCritical' + | 'ItemSpearAttackHit' + | 'ItemSpearAttackMiss' + | 'ItemWoodenSpearAttackHit' + | 'ItemWoodenSpearAttackMiss' + | 'ImitateParched' + | 'ImitateCamelHusk' + | 'ItemSpearUse' + | 'ItemWoodenSpearUse'; + /** + * TODO: remove? + */ + export type LegacyEntityType = + | 'chicken' + | 'cow' + | 'pig' + | 'sheep' + | 'wolf' + | 'villager' + | 'mooshroom' + | 'squid' + | 'rabbit' + | 'bat' + | 'iron_golem' + | 'snow_golem' + | 'ocelot' + | 'horse' + | 'donkey' + | 'mule' + | 'skeleton_horse' + | 'zombie_horse' + | 'polar_bear' + | 'llama' + | 'parrot' + | 'dolphin' + | 'zombie' + | 'creeper' + | 'skeleton' + | 'spider' + | 'zombie_pigman' + | 'slime' + | 'enderman' + | 'silverfish' + | 'cave_spider' + | 'ghast' + | 'magma_cube' + | 'blaze' + | 'zombie_villager' + | 'witch' + | 'stray' + | 'husk' + | 'wither_skeleton' + | 'guardian' + | 'elder_guardian' + | 'npc' + | 'wither' + | 'ender_dragon' + | 'shulker' + | 'endermite' + | 'agent' + | 'vindicator' + | 'phantom' + | 'armor_stand' + | 'tripod_camera' + | 'player' + | 'item' + | 'tnt' + | 'falling_block' + | 'moving_block' + | 'xp_bottle' + | 'xp_orb' + | 'eye_of_ender_signal' + | 'ender_crystal' + | 'fireworks_rocket' + | 'thrown_trident' + | 'turtle' + | 'cat' + | 'shulker_bullet' + | 'fishing_hook' + | 'chalkboard' + | 'dragon_fireball' + | 'arrow' + | 'snowball' + | 'egg' + | 'painting' + | 'minecart' + | 'fireball' + | 'splash_potion' + | 'ender_pearl' + | 'leash_knot' + | 'wither_skull' + | 'boat' + | 'wither_skull_dangerous' + | 'lightning_bolt' + | 'small_fireball' + | 'area_effect_cloud' + | 'hopper_minecart' + | 'tnt_minecart' + | 'chest_minecart' + | 'command_block_minecart' + | 'lingering_potion' + | 'llama_spit' + | 'evocation_fang' + | 'evocation_illager' + | 'vex' + | 'ice_bomb' + | 'balloon' + | 'pufferfish' + | 'salmon' + | 'drowned' + | 'tropicalfish' + | 'cod' + | 'panda'; + export type DeviceOS = + | 'Undefined' + | 'Android' + | 'IOS' + | 'OSX' + | 'FireOS' + | 'GearVR' + | 'Hololens' + | 'Win10' + | 'Win32' + | 'Dedicated' + | 'TVOS' + | 'Orbis' + | 'NintendoSwitch' + | 'Xbox' + | 'WindowsPhone' + | 'Linux'; + export type AbilitySet = { + build?: boolean; + mine?: boolean; + doors_and_switches?: boolean; + open_containers?: boolean; + attack_players?: boolean; + attack_mobs?: boolean; + operator_commands?: boolean; + teleport?: boolean; + invulnerable?: boolean; + flying?: boolean; + may_fly?: boolean; + instant_build?: boolean; + lightning?: boolean; + fly_speed?: boolean; + walk_speed?: boolean; + muted?: boolean; + world_builder?: boolean; + no_clip?: boolean; + privileged_builder?: boolean; + vertical_fly_speed?: boolean; + count?: boolean; + }; + /** + * AbilityLayer represents the abilities of a specific layer, such as the base layer or the spectator layer. + */ + export type AbilityLayers = { + /** Type represents the type of the layer. This is one of the AbilityLayerType constants defined above. */ + type: 'cache' | 'base' | 'spectator' | 'commands' | 'editor' | 'loading_screen'; + /** The abilities that can be toggled between */ + allowed: AbilitySet; + /** The abilities that are currently active */ + enabled: AbilitySet; + /** FlySpeed is the default horizontal fly speed of the layer. */ + fly_speed: number; + /** VerticalFlySpeed is the default vertical fly speed of the layer. */ + vertical_fly_speed: number; + /** WalkSpeed is the default walk speed of the layer. */ + walk_speed: number; + }; + export type CameraPresets = { + /** Name is the name of the preset. Each preset must have their own unique name. */ + name: string; + /** Parent is the name of the preset that this preset extends upon. This can be left empty. */ + parent: string; + position: Vec3fopts; + rotation: Vec2fopts; + rotation_speed?: number; + snap_to_target?: boolean; + horizontal_rotation_limit?: vec2f; + vertical_rotation_limit?: vec2f; + continue_targeting?: boolean; + tracking_radius?: number; + offset?: vec2f; + entity_offset?: vec3f; + radius?: number; + yaw_limit_min?: number; + yaw_limit_max?: number; + audio_listener?: number; + player_effects?: boolean; + aim_assist?: { + preset_id?: string; + target_mode?: 'angle' | 'distance'; + angle?: vec2f; + distance?: number; + }; + control_scheme?: 'locked_player_relative_strafe' | 'camera_relative' | 'camera_relative_strafe' | 'player_relative' | 'player_relative_strafe'; + }; + /** + * CameraRotationOption represents a rotation keyframe option for spline instructions. + */ + export type CameraRotationOption = { + /** Value is the rotation value for the keyframe. */ + value: vec3f; + /** Time is the time of the keyframe within the spline. */ + time: number; + }; + /** + * CameraSplineInstruction represents a camera spline instruction definition. + */ + export type CameraSplineInstruction = { + /** TotalTime is the total time for the spline animation. */ + total_time: number; + /** EaseType is the type of easing function applied to the spline. */ + ease_type: CameraSplineEaseType; + /** Curve is the list of curve points defining the spline. */ + curve: vec3f[]; + /** ProgressKeyFrames is a list of key frames for the spline progress. */ + progress_key_frames: vec2f[]; + /** RotationOptions is a list of rotation keyframes for the spline. */ + rotation_options: CameraRotationOption[]; + }; + export type DisconnectFailReason = + | 'unknown' + | 'cant_connect_no_internet' + | 'no_permissions' + | 'unrecoverable_error' + | 'third_party_blocked' + | 'third_party_no_internet' + | 'third_party_bad_ip' + | 'third_party_no_server_or_server_locked' + | 'version_mismatch' + | 'skin_issue' + | 'invite_session_not_found' + | 'edu_level_settings_missing' + | 'local_server_not_found' + | 'legacy_disconnect' + | 'user_leave_game_attempted' + | 'platform_locked_skins_error' + | 'realms_world_unassigned' + | 'realms_server_cant_connect' + | 'realms_server_hidden' + | 'realms_server_disabled_beta' + | 'realms_server_disabled' + | 'cross_platform_disallowed' + | 'cant_connect' + | 'session_not_found' + | 'client_settings_incompatible_with_server' + | 'server_full' + | 'invalid_platform_skin' + | 'edition_version_mismatch' + | 'edition_mismatch' + | 'level_newer_than_exe_version' + | 'no_fail_occurred' + | 'banned_skin' + | 'timeout' + | 'server_not_found' + | 'outdated_server' + | 'outdated_client' + | 'no_premium_platform' + | 'multiplayer_disabled' + | 'no_wifi' + | 'world_corruption' + | 'no_reason' + | 'disconnected' + | 'invalid_player' + | 'logged_in_other_location' + | 'server_id_conflict' + | 'not_allowed' + | 'not_authenticated' + | 'invalid_tenant' + | 'unknown_packet' + | 'unexpected_packet' + | 'invalid_command_request_packet' + | 'host_suspended' + | 'login_packet_no_request' + | 'login_packet_no_cert' + | 'missing_client' + | 'kicked' + | 'kicked_for_exploit' + | 'kicked_for_idle' + | 'resource_pack_problem' + | 'incompatible_pack' + | 'out_of_storage' + | 'invalid_level' + | 'disconnect_packet_deprecated' + | 'block_mismatch' + | 'invalid_heights' + | 'invalid_widths' + | 'connection_lost' + | 'zombie_connection' + | 'shutdown' + | 'reason_not_set' + | 'loading_state_timeout' + | 'resource_pack_loading_failed' + | 'searching_for_session_loading_screen_failed' + | 'conn_protocol_version' + | 'subsystem_status_error' + | 'empty_auth_from_discovery' + | 'empty_url_from_discovery' + | 'expired_auth_from_discovery' + | 'unknown_signal_service_sign_in_failure' + | 'xbl_join_lobby_failure' + | 'unspecified_client_instance_disconnection' + | 'conn_session_not_found' + | 'conn_create_peer_connection' + | 'conn_ice' + | 'conn_connect_request' + | 'conn_connect_response' + | 'conn_negotiation_timeout' + | 'conn_inactivity_timeout' + | 'stale_connection_being_replaced' + | 'realms_session_not_found' + | 'bad_packet' + | 'conn_failed_to_create_offer' + | 'conn_failed_to_create_answer' + | 'conn_failed_to_set_local_description' + | 'conn_failed_to_set_remote_description' + | 'conn_negotiation_timeout_waiting_for_response' + | 'conn_negotiation_timeout_waiting_for_accept' + | 'conn_incoming_connection_ignored' + | 'conn_signaling_parsing_failure' + | 'conn_signaling_unknown_error' + | 'conn_signaling_unicast_delivery_failed' + | 'conn_signaling_broadcast_delivery_failed' + | 'conn_signaling_generic_delivery_failed' + | 'editor_mismatch_editor_world' + | 'editor_mismatch_vanilla_world' + | 'world_transfer_not_primary_client' + | 'server_shutdown' + | 'game_setup_cancelled' + | 'game_setup_failed' + | 'no_venue' + | 'conn_signalling_sign_in_failed' + | 'session_access_denied' + | 'service_sign_in_issue' + | 'conn_no_signaling_channel' + | 'conn_not_logged_in' + | 'conn_client_signalling_error' + | 'sub_client_login_disabled' + | 'deep_link_trying_to_open_demo_world_while_signed_in' + | 'async_join_task_denied' + | 'realms_timeline_required' + | 'guest_withough_host' + | 'failed_to_join_experience'; + export type FullContainerName = { + container_id: ContainerSlotType; + dynamic_container_id?: number; + }; + export type MovementEffectType = 'GLIDE_BOOST' | 'invalid'; + /** + * BiomeDefinition represents a biome definition in the game. This can be a vanilla biome or a completely + * custom biome. + */ + export type BiomeDefinition = { + /** NameIndex represents the index of the biome name in the string list from BiomeDefinitionListPacket. */ + name_index: number; + /** BiomeID is the biome ID. This is optional and can be empty. */ + biome_id: number; + /** Temperature is the temperature of the biome, used for weather, biome behaviours and sky colour. */ + temperature: number; + /** Downfall is the amount that precipitation affects colours and block changes. */ + downfall: number; + /** Changes leaves turning white in snow */ + snow_foliage: number; + /** Depth ... */ + depth: number; + /** Scale ... */ + scale: number; + /** MapWaterColour is an ARGB value for the water colour on maps in the biome. */ + map_water_colour: number; + /** Rain is true if the biome has rain, false if it is a dry biome. */ + rain: boolean; + tags?: number[]; + chunk_generation?: BiomeChunkGeneration; + }; + /** + * BiomeChunkGeneration represents the information required for the client to generate chunks itself + * to create the illusion of a larger render distance. + */ + export type BiomeChunkGeneration = { + climate?: BiomeClimate; + consolidated_features?: BiomeConsolidatedFeature[]; + mountain_parameters?: BiomeMountainParameters; + surface_material_adjustments?: BiomeElementData[]; + surface_materials?: BiomeSurfaceMaterial; + /** HasDefaultOverworldSurface is true if the biome has a default overworld surface. */ + has_default_overworld_surface: boolean; + /** HasSwampSurface is true if the biome has a swamp surface. */ + has_swamp_surface: boolean; + /** HasFrozenOceanSurface is true if the biome has a frozen ocean surface. */ + has_frozen_ocean_surface: boolean; + /** HasEndSurface is true if the biome has an end surface. */ + has_end_surface: boolean; + mesa_surface?: BiomeMesaSurface; + capped_surface?: BiomeCappedSurface; + overworld_rules?: BiomeOverworldRules; + multi_noise_rules?: BiomeMultiNoiseRules; + legacy_rules?: BiomeConditionalTransformation[]; + replacements_data?: BiomeReplacementData[]; + }; + /** + * BiomeClimate represents the climate of a biome, mainly for ambience but also defines certain behaviours. + */ + export type BiomeClimate = { + /** Temperature is the temperature of the biome, used for weather, biome behaviours and sky colour. */ + temperature: number; + /** Downfall is the amount that precipitation affects colours and block changes. */ + downfall: number; + /** SnowAccumulationMin is the minimum amount of snow that can accumulate in the biome, every 0.125 is another layer of snow. */ + snow_accumulation_min: number; + /** SnowAccumulationMax is the maximum amount of snow that can accumulate in the biome, every 0.125 is another layer of snow. */ + snow_accumulation_max: number; + }; + /** + * BiomeMountainParameters specifies the parameters for a mountain biome. + */ + export type BiomeMountainParameters = { + /** SteepBlock is the runtime ID of the block to use for steep slopes. */ + steep_block: number; + /** NorthSlopes is true if the biome has north slopes. */ + north_slopes: boolean; + /** SouthSlopes is true if the biome has south slopes. */ + south_slopes: boolean; + /** WestSlopes is true if the biome has west slopes. */ + west_slopes: boolean; + /** EastSlopes is true if the biome has east slopes. */ + east_slopes: boolean; + /** TopSlideEnabled is true if the biome has top slide enabled. */ + top_slide_enabled: boolean; + }; + /** + * BiomeSurfaceMaterial specifies the materials to use for the surface layers of the biome. + */ + export type BiomeSurfaceMaterial = { + /** TopBlock is the runtime ID of the block to use for the top layer. */ + top_block: number; + /** MidBlock is the runtime ID to use for the middle layers. */ + mid_block: number; + /** SeaFloorBlock is the runtime ID to use for the sea floor. */ + sea_floor_block: number; + /** FoundationBlock is the runtime ID to use for the foundation layers. */ + foundation_block: number; + /** SeaBlock is the runtime ID to use for the sea layers. */ + sea_block: number; + /** SeaFloorDepth is the depth of the sea floor, in blocks. */ + sea_floor_depth: number; + }; + /** + * BiomeMesaSurface specifies the materials to use for the mesa biome. + */ + export type BiomeMesaSurface = { + /** ClayMaterial is the runtime ID of the block to use for clay layers. */ + clay_material: number; + /** HardClayMaterial is the runtime ID of the block to use for hard clay layers. */ + hard_clay_material: number; + /** BrycePillars is true if the biome has bryce pillars, which are tall spire-like structures. */ + bryce_pillars: boolean; + /** HasForest is true if the biome has a forest. */ + has_forest: boolean; + }; + /** + * BiomeCappedSurface specifies the materials to use for the capped surface of a biome, such as in the Nether. + */ + export type BiomeCappedSurface = { + /** FloorBlocks is a list of runtime IDs to use for the floor blocks. */ + floor_blocks: number[]; + /** CeilingBlocks is a list of runtime IDs to use for the ceiling blocks. */ + ceiling_blocks: number[]; + sea_block?: number; + foundation_block?: number; + beach_block?: number; + }; + /** + * BiomeMultiNoiseRules specifies the rules for multi-noise biomes, which are biomes that are defined by + * multiple noise parameters instead of just temperature and humidity. + */ + export type BiomeMultiNoiseRules = { + /** Temperature is the temperature level of the biome. */ + temperature: number; + /** Humidity is the humidity level of the biome. */ + humidity: number; + /** Altitude is the altitude level of the biome. */ + altitude: number; + /** Weirdness is the weirdness level of the biome. */ + weirdness: number; + /** Weight is the weight of the biome, with a higher weight being more likely to be selected. */ + weight: number; + }; + /** + * BiomeWeight defines the weight for a biome, used for weighted randomness. + */ + export type BiomeWeight = { + /** Biome is the index of the biome name in the string list. */ + biome: number; + /** Weight is the weight of the biome, with a higher weight being more likely to be selected. */ + weight: number; + }; + /** + * BiomeTemperatureWeight defines the weight for a temperature, used for weighted randomness. + */ + export type BiomeTemperatureWeight = { + /** Temperature is the temperature that can be selected. */ + temperature: number; + /** Weight is the weight of the temperature, with a higher weight being more likely to be selected. */ + weight: number; + }; + /** + * BiomeConsolidatedFeature represents a feature that is consolidated into a single feature for the biome. + */ + export type BiomeConsolidatedFeature = { + /** Scatter defines how the feature is scattered in the biome. */ + scatter: BiomeScatterParameter; + /** Feature is the index of the feature's name in the string list. */ + feature: number; + /** Identifier is the index of the feature's identifier in the string list. */ + identifier: number; + /** Pass is the index of the feature's pass in the string list. */ + pass: number; + /** CanUseInternal is true if the feature can use internal features. */ + can_use_internal: boolean; + }; + export type BiomeScatterParameter = { + /** Coordinates is a list of coordinate rules to scatter the feature within. */ + coordinates: BiomeCoordinate[]; + /** EvaluationOrder is the order in which the coordinates are evaluated. */ + evaluation_order: 'xyz' | 'xzy' | 'yxz' | 'yzx' | 'zxy' | 'zyx'; + /** ChancePercentType is the type of expression operation to use for the chance percent. */ + chance_percent_type: number; + /** ChancePercent is the index of the chance expression in the string list. */ + chance_percent: number; + /** ChanceNumerator is the numerator of the chance expression. */ + chance_numerator: number; + /** ChanceDenominator is the denominator of the chance expression. */ + chance_denominator: number; + /** IterationsType is the type of expression operation to use for the iterations. */ + iterations_type: number; + /** Iterations is the index of the iterations expression in the string list. */ + iterations: number; + }; + /** + * BiomeCoordinate specifies coordinate rules for where features can be scattered in the biome. + */ + export type BiomeCoordinate = { + /** MinValueType is the type of expression operation to use for the minimum value. */ + min_value_type: number; + /** MinValue is the index of the minimum value expression in the string list. */ + min_value: number; + /** MaxValueType is the type of expression operation to use for the maximum value. */ + max_value_type: number; + /** MaxValue is the index of the maximum value expression in the string list. */ + max_value: number; + /** GridOffset is the offset of the grid, used for fixed grid and jittered grid distributions. */ + grid_offset: number; + /** GridStepSize is the step size of the grid, used for fixed grid and jittered grid distributions. */ + grid_step_size: number; + /** Distribution is the type of distribution to use for the coordinate. */ + distribution: 'single_valued' | 'uniform' | 'gaussian' | 'inverse_gaussian' | 'fixed_grid' | 'jittered_grid' | 'triangle'; + }; + /** + * BiomeElementData are set rules to adjust the surface materials of the biome. + */ + export type BiomeElementData = { + /** NoiseFrequencyScale is the frequency scale of the noise used to adjust the surface materials. */ + noise_frequency_scale: number; + /** NoiseLowerBound is the minimum noise value required to be selected. */ + noise_lower_bound: number; + /** NoiseUpperBound is the maximum noise value required to be selected. */ + noise_upper_bound: number; + /** HeightMinType is the type of expression operation to use for the minimum height. */ + height_min_type: number; + /** HeightMin is the index of the minimum height expression in the string list. */ + height_min: number; + /** HeightMaxType is the type of expression operation to use for the maximum height. */ + height_max_type: number; + /** HeightMax is the index of the maximum height expression in the string list. */ + height_max: number; + /** AdjustedMaterials is the materials to use for the surface layers of the biome if selected. */ + adjusted_materials: BiomeSurfaceMaterial; + }; + /** + * BiomeOverworldRules specifies a list of transformation rules to apply to different parts of the overworld. + */ + export type BiomeOverworldRules = { + /** HillsTransformations is a list of weighted biome transformations to apply to hills. */ + hills_transformations: BiomeWeight[]; + /** MutateTransformations is a list of weighted biome transformations to apply to mutated biomes. */ + mutate_transformations: BiomeWeight[]; + /** RiverTransformations is a list of weighted biome transformations to apply to rivers. */ + river_transformations: BiomeWeight[]; + /** ShoreTransformations is a list of weighted biome transformations to apply to shores. */ + shore_transformations: BiomeWeight[]; + /** PreHillsEdgeTransformations is a list of conditional transformations to apply to the edges of hills. */ + pre_hills_edge_transformations: BiomeConditionalTransformation[]; + /** PostShoreEdgeTransformations is a list of conditional transformations to apply to the edges of shores. */ + post_shore_edge_transformations: BiomeConditionalTransformation[]; + /** ClimateTransformations is a list of weighted temperature transformations to apply to the biome's climate. */ + climate_transformations: BiomeTemperatureWeight[]; + }; + /** + * BiomeConditionalTransformation is the legacy method of transforming biomes. + */ + export type BiomeConditionalTransformation = { + /** WeightedBiomes is a list of biomes and their weights. */ + weighted_biomes: BiomeWeight[]; + /** ConditionJSON is an index of the condition JSON data in the string list. */ + condition_json: number; + /** MinPassingNeighbours is the minimum number of neighbours that must pass the condition for the transformation to be applied. */ + min_passing_neighbours: number; + }; + /** + * BiomeReplacementData represents data for biome replacements. + */ + export type BiomeReplacementData = { + /** Biome is the biome ID to replace. */ + biome: number; + /** Dimension is the dimension ID where the replacement applies. */ + dimension: number; + /** TargetBiomes is a list of target biome IDs for the replacement. */ + target_biomes: number[]; + /** Amount is the amount of replacement to apply. */ + amount: number; + /** NoiseFrequencyScale is the noise frequency scale to use for replacement selection. */ + noise_frequency_scale: number; + /** ReplacementIndex is the index of the replacement. */ + replacement_index: number; + }; + export type EaseType = + | 'Linear' + | 'Spring' + | 'InQuad' + | 'OutQuad' + | 'InOutQuad' + | 'InCubic' + | 'OutCubic' + | 'InOutCubic' + | 'InQuart' + | 'OutQuart' + | 'InOutQuart' + | 'InQuint' + | 'OutQuint' + | 'InOutQuint' + | 'InSine' + | 'OutSine' + | 'InOutSine' + | 'InExpo' + | 'OutExpo' + | 'InOutExpo' + | 'InCirc' + | 'OutCirc' + | 'InOutCirc' + | 'InBounce' + | 'OutBounce' + | 'InOutBounce' + | 'InBack' + | 'OutBack' + | 'InOutBack' + | 'InElastic' + | 'OutElastic' + | 'InOutElastic'; + export type CameraSplineEaseType = 'catmull_rom' | 'linear'; + export type ParameterKeyframeValue = { + /** Time is the time of the keyframe. */ + time: number; + /** Value is the value at the keyframe. */ + value: vec3f; + }; + export type GraphicsOverrideParameterType = + | 'sky_zenith_color' + | 'sky_horizon_color' + | 'horizon_blend_min' + | 'horizon_blend_max' + | 'horizon_blend_start' + | 'horizon_blend_mie_start' + | 'rayleigh_strength' + | 'sun_mie_strength' + | 'moon_mie_strength' + | 'sun_glare_shape'; + export type DebugMarkerData = { + text: string; + position: vec3f; + color: number; + duration: bigint; + }; + export type mcpe_packet = { + name: + | 'login' + | 'play_status' + | 'server_to_client_handshake' + | 'client_to_server_handshake' + | 'disconnect' + | 'resource_packs_info' + | 'resource_pack_stack' + | 'resource_pack_client_response' + | 'text' + | 'set_time' + | 'start_game' + | 'add_player' + | 'add_entity' + | 'remove_entity' + | 'add_item_entity' + | 'server_post_move' + | 'take_item_entity' + | 'move_entity' + | 'move_player' + | 'rider_jump' + | 'update_block' + | 'add_painting' + | 'tick_sync' + | 'level_sound_event_old' + | 'level_event' + | 'block_event' + | 'entity_event' + | 'mob_effect' + | 'update_attributes' + | 'inventory_transaction' + | 'mob_equipment' + | 'mob_armor_equipment' + | 'interact' + | 'block_pick_request' + | 'entity_pick_request' + | 'player_action' + | 'hurt_armor' + | 'set_entity_data' + | 'set_entity_motion' + | 'set_entity_link' + | 'set_health' + | 'set_spawn_position' + | 'animate' + | 'respawn' + | 'container_open' + | 'container_close' + | 'player_hotbar' + | 'inventory_content' + | 'inventory_slot' + | 'container_set_data' + | 'crafting_data' + | 'crafting_event' + | 'gui_data_pick_item' + | 'adventure_settings' + | 'block_entity_data' + | 'player_input' + | 'level_chunk' + | 'set_commands_enabled' + | 'set_difficulty' + | 'change_dimension' + | 'set_player_game_type' + | 'player_list' + | 'simple_event' + | 'event' + | 'spawn_experience_orb' + | 'clientbound_map_item_data' + | 'map_info_request' + | 'request_chunk_radius' + | 'chunk_radius_update' + | 'game_rules_changed' + | 'camera' + | 'boss_event' + | 'show_credits' + | 'available_commands' + | 'command_request' + | 'command_block_update' + | 'command_output' + | 'update_trade' + | 'update_equipment' + | 'resource_pack_data_info' + | 'resource_pack_chunk_data' + | 'resource_pack_chunk_request' + | 'transfer' + | 'play_sound' + | 'stop_sound' + | 'set_title' + | 'add_behavior_tree' + | 'structure_block_update' + | 'show_store_offer' + | 'purchase_receipt' + | 'player_skin' + | 'sub_client_login' + | 'initiate_web_socket_connection' + | 'set_last_hurt_by' + | 'book_edit' + | 'npc_request' + | 'photo_transfer' + | 'modal_form_request' + | 'modal_form_response' + | 'server_settings_request' + | 'server_settings_response' + | 'show_profile' + | 'set_default_game_type' + | 'remove_objective' + | 'set_display_objective' + | 'set_score' + | 'lab_table' + | 'update_block_synced' + | 'move_entity_delta' + | 'set_scoreboard_identity' + | 'set_local_player_as_initialized' + | 'update_soft_enum' + | 'network_stack_latency' + | 'script_custom_event' + | 'spawn_particle_effect' + | 'available_entity_identifiers' + | 'level_sound_event_v2' + | 'network_chunk_publisher_update' + | 'biome_definition_list' + | 'level_sound_event' + | 'level_event_generic' + | 'lectern_update' + | 'video_stream_connect' + | 'client_cache_status' + | 'on_screen_texture_animation' + | 'map_create_locked_copy' + | 'structure_template_data_export_request' + | 'structure_template_data_export_response' + | 'update_block_properties' + | 'client_cache_blob_status' + | 'client_cache_miss_response' + | 'education_settings' + | 'emote' + | 'multiplayer_settings' + | 'settings_command' + | 'anvil_damage' + | 'completed_using_item' + | 'network_settings' + | 'player_auth_input' + | 'creative_content' + | 'player_enchant_options' + | 'item_stack_request' + | 'item_stack_response' + | 'player_armor_damage' + | 'code_builder' + | 'update_player_game_type' + | 'emote_list' + | 'position_tracking_db_broadcast' + | 'position_tracking_db_request' + | 'debug_info' + | 'packet_violation_warning' + | 'motion_prediction_hints' + | 'animate_entity' + | 'camera_shake' + | 'player_fog' + | 'correct_player_move_prediction' + | 'item_registry' + | 'filter_text_packet' + | 'debug_renderer' + | 'sync_entity_property' + | 'add_volume_entity' + | 'remove_volume_entity' + | 'simulation_type' + | 'npc_dialogue' + | 'edu_uri_resource_packet' + | 'create_photo' + | 'update_subchunk_blocks' + | 'photo_info_request' + | 'subchunk' + | 'subchunk_request' + | 'client_start_item_cooldown' + | 'script_message' + | 'code_builder_source' + | 'ticking_areas_load_status' + | 'dimension_data' + | 'agent_action' + | 'change_mob_property' + | 'lesson_progress' + | 'request_ability' + | 'request_permissions' + | 'toast_request' + | 'update_abilities' + | 'update_adventure_settings' + | 'death_info' + | 'editor_network' + | 'feature_registry' + | 'server_stats' + | 'request_network_settings' + | 'game_test_request' + | 'game_test_results' + | 'update_client_input_locks' + | 'client_cheat_ability' + | 'camera_presets' + | 'unlocked_recipes' + | 'camera_instruction' + | 'compressed_biome_definitions' + | 'trim_data' + | 'open_sign' + | 'agent_animation' + | 'refresh_entitlements' + | 'toggle_crafter_slot_request' + | 'set_player_inventory_options' + | 'set_hud' + | 'award_achievement' + | 'clientbound_close_form' + | 'serverbound_loading_screen' + | 'jigsaw_structure_data' + | 'current_structure_feature' + | 'serverbound_diagnostics' + | 'camera_aim_assist' + | 'container_registry_cleanup' + | 'movement_effect' + | 'set_movement_authority' + | 'camera_aim_assist_presets' + | 'client_camera_aim_assist' + | 'client_movement_prediction_sync' + | 'update_client_options' + | 'player_video_capture' + | 'player_update_entity_overrides' + | 'player_location' + | 'clientbound_controls_scheme' + | 'server_script_debug_drawer' + | 'serverbound_pack_setting_change' + | 'clientbound_data_store' + | 'graphics_override_parameter' + | 'serverbound_data_store'; + params: + | packet_login + | packet_play_status + | packet_server_to_client_handshake + | packet_client_to_server_handshake + | packet_disconnect + | packet_resource_packs_info + | packet_resource_pack_stack + | packet_resource_pack_client_response + | packet_text + | packet_set_time + | packet_start_game + | packet_add_player + | packet_add_entity + | packet_remove_entity + | packet_add_item_entity + | packet_take_item_entity + | packet_move_entity + | packet_move_player + | packet_rider_jump + | packet_update_block + | packet_add_painting + | packet_tick_sync + | packet_level_sound_event_old + | packet_level_event + | packet_block_event + | packet_entity_event + | packet_mob_effect + | packet_update_attributes + | packet_inventory_transaction + | packet_mob_equipment + | packet_mob_armor_equipment + | packet_interact + | packet_block_pick_request + | packet_entity_pick_request + | packet_player_action + | packet_hurt_armor + | packet_set_entity_data + | packet_set_entity_motion + | packet_set_entity_link + | packet_set_health + | packet_set_spawn_position + | packet_animate + | packet_respawn + | packet_container_open + | packet_container_close + | packet_player_hotbar + | packet_inventory_content + | packet_inventory_slot + | packet_container_set_data + | packet_crafting_data + | packet_crafting_event + | packet_gui_data_pick_item + | packet_adventure_settings + | packet_block_entity_data + | packet_player_input + | packet_level_chunk + | packet_set_commands_enabled + | packet_set_difficulty + | packet_change_dimension + | packet_set_player_game_type + | packet_player_list + | packet_simple_event + | packet_event + | packet_spawn_experience_orb + | packet_clientbound_map_item_data + | packet_map_info_request + | packet_request_chunk_radius + | packet_chunk_radius_update + | packet_game_rules_changed + | packet_camera + | packet_boss_event + | packet_show_credits + | packet_available_commands + | packet_command_request + | packet_command_block_update + | packet_command_output + | packet_update_trade + | packet_update_equipment + | packet_resource_pack_data_info + | packet_resource_pack_chunk_data + | packet_resource_pack_chunk_request + | packet_transfer + | packet_play_sound + | packet_stop_sound + | packet_set_title + | packet_add_behavior_tree + | packet_structure_block_update + | packet_show_store_offer + | packet_purchase_receipt + | packet_player_skin + | packet_sub_client_login + | packet_initiate_web_socket_connection + | packet_set_last_hurt_by + | packet_book_edit + | packet_npc_request + | packet_photo_transfer + | packet_modal_form_request + | packet_modal_form_response + | packet_server_settings_request + | packet_server_settings_response + | packet_show_profile + | packet_set_default_game_type + | packet_remove_objective + | packet_set_display_objective + | packet_set_score + | packet_lab_table + | packet_update_block_synced + | packet_move_entity_delta + | packet_set_scoreboard_identity + | packet_set_local_player_as_initialized + | packet_update_soft_enum + | packet_network_stack_latency + | packet_script_custom_event + | packet_spawn_particle_effect + | packet_available_entity_identifiers + | packet_level_sound_event_v2 + | packet_network_chunk_publisher_update + | packet_biome_definition_list + | packet_level_sound_event + | packet_level_event_generic + | packet_lectern_update + | packet_video_stream_connect + | packet_client_cache_status + | packet_on_screen_texture_animation + | packet_map_create_locked_copy + | packet_structure_template_data_export_request + | packet_structure_template_data_export_response + | packet_update_block_properties + | packet_client_cache_blob_status + | packet_client_cache_miss_response + | packet_education_settings + | packet_emote + | packet_multiplayer_settings + | packet_settings_command + | packet_anvil_damage + | packet_completed_using_item + | packet_network_settings + | packet_player_auth_input + | packet_creative_content + | packet_player_enchant_options + | packet_item_stack_request + | packet_item_stack_response + | packet_player_armor_damage + | packet_code_builder + | packet_update_player_game_type + | packet_emote_list + | packet_position_tracking_db_request + | packet_position_tracking_db_broadcast + | packet_debug_info + | packet_packet_violation_warning + | packet_motion_prediction_hints + | packet_animate_entity + | packet_camera_shake + | packet_player_fog + | packet_correct_player_move_prediction + | packet_item_registry + | packet_filter_text_packet + | packet_debug_renderer + | packet_sync_entity_property + | packet_add_volume_entity + | packet_remove_volume_entity + | packet_simulation_type + | packet_npc_dialogue + | packet_edu_uri_resource_packet + | packet_create_photo + | packet_update_subchunk_blocks + | packet_photo_info_request + | packet_subchunk + | packet_subchunk_request + | packet_client_start_item_cooldown + | packet_script_message + | packet_code_builder_source + | packet_ticking_areas_load_status + | packet_dimension_data + | packet_agent_action + | packet_change_mob_property + | packet_lesson_progress + | packet_request_ability + | packet_request_permissions + | packet_toast_request + | packet_update_abilities + | packet_update_adventure_settings + | packet_death_info + | packet_editor_network + | packet_feature_registry + | packet_server_stats + | packet_request_network_settings + | packet_game_test_request + | packet_game_test_results + | packet_update_client_input_locks + | packet_client_cheat_ability + | packet_camera_presets + | packet_unlocked_recipes + | packet_camera_instruction + | packet_compressed_biome_definitions + | packet_trim_data + | packet_open_sign + | packet_agent_animation + | packet_refresh_entitlements + | packet_toggle_crafter_slot_request + | packet_set_player_inventory_options + | packet_set_hud + | packet_award_achievement + | packet_server_post_move + | packet_clientbound_close_form + | packet_serverbound_loading_screen + | packet_jigsaw_structure_data + | packet_current_structure_feature + | packet_serverbound_diagnostics + | packet_camera_aim_assist + | packet_container_registry_cleanup + | packet_movement_effect + | packet_set_movement_authority + | packet_camera_aim_assist_presets + | packet_client_camera_aim_assist + | packet_client_movement_prediction_sync + | packet_update_client_options + | packet_player_video_capture + | packet_player_update_entity_overrides + | packet_player_location + | packet_clientbound_controls_scheme + | packet_server_script_debug_drawer + | packet_serverbound_pack_setting_change + | packet_clientbound_data_store + | packet_graphics_override_parameter + | packet_serverbound_data_store; + }; + /** + * load the packet map file (auto-generated) + * # Login Sequence + * The login process is as follows: + * + * * C→S: [Login](#packet_login) + * * S→C: [Server To Client Handshake](#packet_server_to_client_handshake) + * * C→S: [Client To Server Handshake](#packet_client_to_server_handshake) + * * S→C: [Play Status (Login success)](#packet_play_status) + * * To spawn, the following packets should be sent, in order, after the ones above: + * * S→C: [Resource Packs Info](#packet_resource_packs_info) + * * C→S: [Resource Pack Client Response](#packet_resource_pack_client_response) + * * S→C: [Resource Pack Stack](#packet_resource_pack_stack) + * * C→S: [Resource Pack Client Response](#packet_resource_pack_client_response) + * * S→C: [Start Game](#packet_start_game) + * * S→C: [Item Registry](#packet_item_registry) + * * S→C: [Creative Content](#packet_creative_content) + * * S→C: [Biome Definition List](#packet_biome_definition_list) + * * S→C: [Chunks](#packet_level_chunk) + * * S→C: [Play Status (Player spawn)](#packet_play_status) + * + * If there are no resource packs being sent, a Resource Pack Stack can be sent directly + * after Resource Packs Info to avoid the client responses. + * + * === + */ + export type packet_login = { + /** Protocol version (Big Endian!) */ + protocol_version: number; + /** The structure of the login tokens has changed in 1.21.90. The encapsulated data is now a JSON object with a stringified `Certificate`. */ + tokens: LoginTokens; + }; + export type LoginTokens = { + /** JSON array of JWT data: contains the display name, UUID and XUID It should be signed by the Mojang public key For 1.21.90+, the 'identity' field is a Little-Endian length-prefixed JSON-encoded string. This JSON object must contain a 'Certificate' key, whose value is a *stringified* JSON object that holds the actual JWT 'chain' array. */ + identity: LittleString; + /** JWT containing skin and other client data. */ + client: LittleString; + }; + export type packet_play_status = { + status: + | 'login_success' + | 'failed_client' + | 'failed_spawn' + | 'player_spawn' + | 'failed_invalid_tenant' + | 'failed_vanilla_edu' + | 'failed_edu_vanilla' + | 'failed_server_full' + | 'failed_editor_vanilla_mismatch' + | 'failed_vanilla_editor_mismatch'; + }; + export type packet_server_to_client_handshake = { + /** Contains the salt to complete the Diffie-Hellman key exchange */ + token: string; + }; + /** + * Sent by the client in response to a Server To Client Handshake packet + * sent by the server. It is the first encrypted packet in the login handshake + * and serves as a confirmation that encryption is correctly initialized client side. + * It has no fields. + */ + export type packet_client_to_server_handshake = {}; + export type packet_disconnect = { + reason: DisconnectFailReason; + hide_disconnect_reason: boolean; + message?: string; + filtered_message?: string; + }; + export type packet_resource_packs_info = { + /** If the resource pack requires the client accept it. */ + must_accept: boolean; + /** HasAddons specifies if any of the resource packs contain addons in them. If set to true, only clients that support addons will be able to download them. */ + has_addons: boolean; + /** If scripting is enabled. */ + has_scripts: boolean; + /** ForceDisableVibrantVisuals specifies if the vibrant visuals feature should be forcibly disabled on the server. If set to true, the server will ensure that vibrant visuals are not enabled, regardless of the client's settings. */ + disable_vibrant_visuals: boolean; + world_template: { + /** WorldTemplateUUID is teh UUID of the template that has been used to generate the world. Templates can be downloaded from the marketplace or installed via '.mctemplate' files. If the world was not generated from a template, this field is empty. */ + uuid: string; + /** WorldTemplateVersion is the version of the world template that has been used to generate the world. If the world was not generated from a template, this field is empty. */ + version: string; + }; + /** A list of resource packs that the client needs to download before joining the server. The order of these resource packs is not relevant in this packet. It is however important in the Resource Pack Stack packet. */ + texture_packs: TexturePackInfos; + }; + export type packet_resource_pack_stack = { + /** If the resource pack must be accepted for the player to join the server. */ + must_accept: boolean; + /** [inline] */ + resource_packs: ResourcePackIdVersions; + game_version: string; + experiments: Experiments; + experiments_previously_used: boolean; + has_editor_packs: boolean; + }; + export type packet_resource_pack_client_response = { + response_status: 'none' | 'refused' | 'send_packs' | 'have_all_packs' | 'completed'; + /** All of the pack IDs. */ + resourcepackids: ResourcePackIds; + }; + /** + * Sent by the client to the server to send chat messages, and by the server to the client + * to forward or send messages, which may be chat, popups, tips etc. + * # https://github.com/pmmp/PocketMine-MP/blob/a43b46a93cb127f037c879b5d8c29cda251dd60c/src/pocketmine/network/mcpe/protocol/TextPacket.php + * # https://github.com/Sandertv/gophertunnel/blob/05ac3f843dd60d48b9ca0ab275cda8d9e85d8c43/minecraft/protocol/packet/text.go + */ + export type packet_text = { + /** NeedsTranslation specifies if any of the messages need to be translated. */ + needs_translation: boolean; + category: 'message_only' | 'authored' | 'parameters'; + source_name?: string; + message?: string; + parameters?: string[]; + type: 'raw' | 'chat' | 'translation' | 'popup' | 'jukebox_popup' | 'tip' | 'system' | 'whisper' | 'announcement' | 'json_whisper' | 'json' | 'json_announcement'; + xuid: string; + platform_chat_id: string; + has_filtered_message: boolean; + filtered_message?: string; + }; + /** + * For additional information and examples of all the chat types above, see here: https://imgur.com/a/KhcFscg + * Sent by the server to update the current time client-side. The client actually advances time + * client-side by itself, so this packet does not need to be sent each tick. It is merely a means + * of synchronizing time between server and client. + */ + export type packet_set_time = { + /** Time is the current time. The time is not limited to 24000 (time of day), but continues progressing after that. */ + time: number; + }; + /** + * Sent by the server to send information about the world the player will be spawned in. + */ + export type packet_start_game = { + /** The unique ID of the player. The unique ID is a value that remains consistent across different sessions of the same world, but most unofficial servers simply fill the runtime ID of the entity out for this field. */ + entity_id: bigint; + /** The runtime ID of the player. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + runtime_entity_id: bigint; + /** PlayerGameMode is the game mode the player currently has. It is a value from 0-4, with 0 being survival mode, 1 being creative mode, 2 being adventure mode, 3 being survival spectator and 4 being creative spectator. This field may be set to 5 to make the client fall back to the game mode set in the WorldGameMode field. */ + player_gamemode: GameMode; + /** The spawn position of the player in the world. In servers this is often the same as the world's spawn position found below. */ + player_position: vec3f; + /** The pitch and yaw of the player */ + rotation: vec2f; + /** The seed used to generate the world. */ + seed: bigint; + biome_type: number; + biome_name: string; + /** Dimension is the ID of the dimension that the player spawns in. It is a value from 0-2, with 0 being the overworld, 1 being the nether and 2 being the end. */ + dimension: 'overworld' | 'nether' | 'end'; + /** Generator is the generator used for the world. It is a value from 0-4, with 0 being old limited worlds, 1 being infinite worlds, 2 being flat worlds, 3 being nether worlds and 4 being end worlds. A value of 0 will actually make the client stop rendering chunks you send beyond the world limit. As of 1.21.80, protocol.PlayerMovementModeServer is the minimum requirement for MovementType. */ + generator: number; + /** The world game mode that a player gets when it first spawns in the world. It is shown in the settings and is used if the Player Gamemode is set to 5. */ + world_gamemode: GameMode; + /** Specifies if the game is locked to "hardcore" mode or not, meaning the world will be unplayable after player dies in survival game mode. Persists even after switching player or world game modes. */ + hardcore: boolean; + /** Difficulty is the difficulty of the world. It is a value from 0-3, with 0 being peaceful, 1 being easy, 2 being normal and 3 being hard. */ + difficulty: number; + /** The block on which the world spawn of the world. This coordinate has no effect on the place that the client spawns, but it does have an effect on the direction that a compass poInts. */ + spawn_position: BlockCoordinates; + /** Defines if achievements are disabled in the world. The client crashes if this value is set to true while the player's or the world's game mode is creative, and it's recommended to simply always set this to false as a server. */ + achievements_disabled: boolean; + /** EditorWorldType is a value to dictate the type of editor mode, a special mode recently introduced adding "powerful tools for editing worlds, intended for experienced creators." */ + editor_world_type: 'not_editor' | 'project' | 'test_level' | 'realms_upload'; + /** Whether the world was created in editor mode */ + created_in_editor: boolean; + /** Whether the world was exported from editor mode */ + exported_from_editor: boolean; + /** The time at which the day cycle was locked if the day cycle is disabled using the respective game rule. The client will maIntain this time as Boolean as the day cycle is disabled. */ + day_cycle_stop_time: number; + /** Some Minecraft: Education Edition field that specifies what 'region' the world was from, with 0 being None, 1 being RestOfWorld, and 2 being China. The actual use of this field is unknown. */ + edu_offer: number; + /** Specifies if the world has education edition features enabled, such as the blocks or entities specific to education edition. */ + edu_features_enabled: boolean; + edu_product_uuid: string; + /** The level specifying the Intensity of the rain falling. When set to 0, no rain falls at all. */ + rain_level: number; + lightning_level: number; + /** The level specifying the Intensity of the thunder. This may actually be set independently from the rain level, meaning dark clouds can be produced without rain. */ + has_confirmed_platform_locked_content: boolean; + /** Specifies if the world is a multi-player game. This should always be set to true for servers. */ + is_multiplayer: boolean; + /** Specifies if LAN broadcast was Intended to be enabled for the world. */ + broadcast_to_lan: boolean; + /** The mode used to broadcast the joined game across XBOX Live. */ + xbox_live_broadcast_mode: number; + /** The mode used to broadcast the joined game across the platform. */ + platform_broadcast_mode: number; + /** If commands are enabled for the player. It is recommended to always set this to true on the server, as setting it to false means the player cannot, under any circumstance, use a command. */ + enable_commands: boolean; + /** Specifies if the texture pack the world might hold is required, meaning the client was forced to download it before joining. */ + is_texturepacks_required: boolean; + /** Defines game rules currently active with their respective values. The value of these game rules may be either 'bool', 'Int32' or 'Float32'. Some game rules are server side only, and don't necessarily need to be sent to the client. */ + gamerules: GameRuleVarint[]; + experiments: Experiments; + experiments_previously_used: boolean; + /** Specifies if the world had the bonus map setting enabled when generating it. It does not have any effect client-side. */ + bonus_chest: boolean; + /** Specifies if the world has the start with map setting enabled, meaning each joining player obtains a map. This should always be set to false, because the client obtains a map all on its own accord if this is set to true. */ + map_enabled: boolean; + /** The permission level of the player. It is a value from 0-3, with 0 being visitor, 1 being member, 2 being operator and 3 being custom. */ + permission_level: PermissionLevel; + /** The radius around the player in which chunks are ticked. Most servers set this value to a fixed number, as it does not necessarily affect anything client-side. */ + server_chunk_tick_range: number; + /** Specifies if the texture pack of the world is locked, meaning it cannot be disabled from the world. This is typically set for worlds on the marketplace that have a dedicated texture pack. */ + has_locked_behavior_pack: boolean; + /** Specifies if the texture pack of the world is locked, meaning it cannot be disabled from the world. This is typically set for worlds on the marketplace that have a dedicated texture pack. */ + has_locked_resource_pack: boolean; + /** Specifies if the world from the server was from a locked world template. For servers this should always be set to false. */ + is_from_locked_world_template: boolean; + msa_gamertags_only: boolean; + /** Specifies if the world from the server was from a locked world template. For servers this should always be set to false. */ + is_from_world_template: boolean; + /** Specifies if the world was a template that locks all settings that change properties above in the settings GUI. It is recommended to set this to true for servers that do not allow things such as setting game rules through the GUI. */ + is_world_template_option_locked: boolean; + /** A hack that Mojang put in place to preserve backwards compatibility with old villagers. The his never actually read though, so it has no functionality. */ + only_spawn_v1_villagers: boolean; + /** PersonaDisabled is true if persona skins are disabled for the current game session. */ + persona_disabled: boolean; + /** CustomSkinsDisabled is true if custom skins are disabled for the current game session. */ + custom_skins_disabled: boolean; + /** EmoteChatMuted specifies if players will be sent a chat message when using certain emotes. */ + emote_chat_muted: boolean; + /** The version of the game from which Vanilla features will be used. The exact function of this field isn't clear. */ + game_version: string; + limited_world_width: number; + limited_world_length: number; + is_new_nether: boolean; + edu_resource_uri: EducationSharedResourceURI; + experimental_gameplay_override: boolean; + /** ChatRestrictionLevel specifies the level of restriction on in-game chat. */ + chat_restriction_level: 'none' | 'dropped' | 'disabled'; + /** DisablePlayerInteractions is true if the client should ignore other players when interacting with the world. */ + disable_player_interactions: boolean; + server_identifier: string; + world_identifier: string; + scenario_identifier: string; + owner_identifier: string; + /** A base64 encoded world ID that is used to identify the world. */ + level_id: string; + /** The name of the world that the player is joining. Note that this field shows up above the player list for the rest of the game session, and cannot be changed. Setting the server name to this field is recommended. */ + world_name: string; + /** A UUID specific to the premium world template that might have been used to generate the world. Servers should always fill out an empty String for this. */ + premium_world_template_id: string; + /** Specifies if the world was a trial world, meaning features are limited and there is a time limit on the world. */ + is_trial: boolean; + /** RewindHistorySize is the amount of history to keep at maximum */ + rewind_history_size: number; + /** ServerAuthoritativeBlockBreaking specifies if block breaking should be sent through packet.PlayerAuthInput or not. This field is somewhat redundant as it is always enabled if server authoritative movement is enabled. */ + server_authoritative_block_breaking: boolean; + current_tick: bigint; + enchantment_seed: number; + block_properties: BlockProperties; + multiplayer_correlation_id: string; + server_authoritative_inventory: boolean; + engine: string; + property_data: any; + block_pallette_checksum: bigint; + world_template_id: string; + client_side_generation: boolean; + block_network_ids_are_hashes: boolean; + server_controlled_sound: boolean; + }; + export type packet_add_player = { + /** UUID is the UUID of the player. It is the same UUID that the client sent in the Login packet at the start of the session. A player with this UUID must exist in the player list (built up using the Player List packet) for it to show up in-game. */ + uuid: string; + /** Username is the name of the player. This username is the username that will be set as the initial name tag of the player. */ + username: string; + /** The runtime ID of the player. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + runtime_id: bigint; + /** An identifier only set for particular platforms when chatting (presumably only for Nintendo Switch). It is otherwise an empty string, and is used to decide which players are able to chat with each other. */ + platform_chat_id: string; + /** Position is the position to spawn the player on. If the player is on a distance that the viewer cannot see it, the player will still show up if the viewer moves closer. */ + position: vec3f; + /** Velocity is the initial velocity the player spawns with. This velocity will initiate client side movement of the player. */ + velocity: vec3f; + /** Pitch is the vertical rotation of the player. Facing straight forward yields a pitch of 0. Pitch is measured in degrees. */ + pitch: number; + /** Yaw is the horizontal rotation of the player. Yaw is also measured in degrees. */ + yaw: number; + /** HeadYaw is the same as Yaw, except that it applies specifically to the head of the player. A different value for HeadYaw than Yaw means that the player will have its head turned. */ + head_yaw: number; + /** HeldItem is the item that the player is holding. The item is shown to the viewer as soon as the player itself shows up. Needless to say that this field is rather pointless, as additional packets still must be sent for armour to show up. */ + held_item: Item; + /** GameType is the game type of the player. If set to GameTypeSpectator, the player will not be shown to viewers. */ + gamemode: GameMode; + /** EntityMetadata is a map of entity metadata, which includes flags and data properties that alter in particular the way the player looks. Flags include ones such as 'on fire' and 'sprinting'. The metadata values are indexed by their property key. */ + metadata: MetadataDictionary; + /** EntityProperties holds lists of entity properties that define specific attributes of an entity. As of v1.19.40, the vanilla server does not use these properties, however they are still supported by the protocol. */ + properties: EntityProperties; + /** The unique ID of the player. The unique ID is a value that remains consistent across different sessions of the same world, but most unoffical servers simply fill the runtime ID of the player out for this field. */ + unique_id: bigint; + permission_level: PermissionLevel; + command_permission: CommandPermissionLevel; + /** AbilityLayer represents the abilities of a specific layer, such as the base layer or the spectator layer. */ + abilities: AbilityLayers[]; + /** EntityLinks is a list of entity links that are currently active on the player. These links alter the way the player shows up when first spawned in terms of it shown as riding an entity. Setting these links is important for new viewers to see the player is riding another entity. */ + links: Links; + /** DeviceID is the device ID set in one of the files found in the storage of the device of the player. It may be changed freely, so it should not be relied on for anything. */ + device_id: string; + /** BuildPlatform is the build platform/device OS of the player that is about to be added, as it sent in the Login packet when joining. */ + device_os: DeviceOS; + }; + export type packet_add_entity = { + /** EntityUniqueID is the unique ID of the entity. The unique ID is a value that remains consistent across different sessions of the same world, but most servers simply fill the runtime ID of the entity out for */ + unique_id: bigint; + /** EntityRuntimeID is the runtime ID of the entity. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + runtime_id: bigint; + /** EntityType is the string entity type of the entity, for example 'minecraft:skeleton'. A list of these entities may be found online. */ + entity_type: string; + /** Position is the position to spawn the entity on. If the entity is on a distance that the player cannot see it, the entity will still show up if the player moves closer. */ + position: vec3f; + /** Velocity is the initial velocity the entity spawns with. This velocity will initiate client side movement of the entity. */ + velocity: vec3f; + /** Pitch is the vertical rotation of the entity. Facing straight forward yields a pitch of 0. Pitch is measured in degrees. */ + pitch: number; + /** Yaw is the horizontal rotation of the entity. Yaw is also measured in degrees. */ + yaw: number; + /** HeadYaw is the same as Yaw, except that it applies specifically to the head of the entity. A different value for HeadYaw than Yaw means that the entity will have its head turned. */ + head_yaw: number; + /** BodyYaw is the same as Yaw, except that it applies specifically to the body of the entity. A different value for BodyYaw than HeadYaw means that the entity will have its body turned, although it is unclear what the difference between BodyYaw and Yaw is. */ + body_yaw: number; + /** Attributes is a slice of attributes that the entity has. It includes attributes such as its health, movement speed, etc. */ + attributes: EntityAttributes; + /** EntityMetadata is a map of entity metadata, which includes flags and data properties that alter in particular the way the entity looks. Flags include ones such as 'on fire' and 'sprinting'. The metadata values are indexed by their property key. */ + metadata: MetadataDictionary; + /** EntityProperties holds lists of entity properties that define specific attributes of an entity. As of v1.19.40, the vanilla server does not use these properties, however they are still supported by the protocol. */ + properties: EntityProperties; + /** EntityLinks is a list of entity links that are currently active on the entity. These links alter the way the entity shows up when first spawned in terms of it shown as riding an entity. Setting these links is important for new viewers to see the entity is riding another entity. */ + links: Links; + }; + export type packet_remove_entity = { + entity_id_self: bigint; + }; + export type packet_add_item_entity = { + entity_id_self: bigint; + runtime_entity_id: bigint; + item: Item; + position: vec3f; + velocity: vec3f; + metadata: MetadataDictionary; + is_from_fishing: boolean; + }; + export type packet_take_item_entity = { + runtime_entity_id: bigint; + target: number; + }; + /** + * MoveActorAbsolute is sent by the server to move an entity to an absolute position. It is typically used + * for movements where high accuracy isn't needed, such as for long range teleporting. + */ + export type packet_move_entity = { + /** EntityRuntimeID is the runtime ID of the entity. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + runtime_entity_id: bigint; + /** Flags is a combination of flags that specify details of the movement. It is a combination of the flags above. */ + flags: number; + /** Position is the position to spawn the entity on. If the entity is on a distance that the player cannot see it, the entity will still show up if the player moves closer. */ + position: vec3f; + /** Rotation is a Vec3 holding the X, Y and Z rotation of the entity after the movement. This is a Vec3 for the reason that projectiles like arrows don't have yaw/pitch, but do have roll. */ + rotation: Rotation; + }; + /** + * MovePlayer is sent by players to send their movement to the server, and by the server to update the + * movement of player entities to other players. + */ + export type packet_move_player = { + /** EntityRuntimeID is the runtime ID of the player. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + runtime_id: number; + /** Position is the position to spawn the player on. If the player is on a distance that the viewer cannot see it, the player will still show up if the viewer moves closer. */ + position: vec3f; + /** Pitch is the vertical rotation of the player. Facing straight forward yields a pitch of 0. Pitch is measured in degrees. */ + pitch: number; + /** Yaw is the horizontal rotation of the player. Yaw is also measured in degrees */ + yaw: number; + /** HeadYaw is the same as Yaw, except that it applies specifically to the head of the player. A different value for HeadYaw than Yaw means that the player will have its head turned */ + head_yaw: number; + /** Mode is the mode of the movement. It specifies the way the player's movement should be shown to other players. It is one of the constants below. */ + mode: 'normal' | 'reset' | 'teleport' | 'rotation'; + /** OnGround specifies if the player is considered on the ground. Note that proxies or hacked clients could fake this to always be true, so it should not be taken for granted. */ + on_ground: boolean; + /** RiddenEntityRuntimeID is the runtime ID of the entity that the player might currently be riding. If not riding, this should be left 0. */ + ridden_runtime_id: number; + teleport?: { + cause: 'unknown' | 'projectile' | 'chorus_fruit' | 'command' | 'behavior'; + source_entity_type: LegacyEntityType; + }; + tick: bigint; + }; + /** + * Removed in 1.21.80 + */ + export type packet_rider_jump = { + jump_strength: number; + }; + /** + * UpdateBlock is sent by the server to update a block client-side, without resending the entire chunk that + * the block is located in. It is particularly useful for small modifications like block breaking/placing. + */ + export type packet_update_block = { + /** Position is the block position at which a block is updated. */ + position: BlockCoordinates; + /** NewBlockRuntimeID is the runtime ID of the block that is placed at Position after sending the packet to the client. */ + block_runtime_id: number; + /** Flags is a combination of flags that specify the way the block is updated client-side. It is a combination of the flags above, but typically sending only the BlockUpdateNetwork flag is sufficient. */ + flags: UpdateBlockFlags; + /** Layer is the world layer on which the block is updated. For most blocks, this is the first layer, as that layer is the default layer to place blocks on, but for blocks inside of each other, this differs. */ + layer: number; + }; + export type UpdateBlockFlags = { + neighbors?: boolean; + network?: boolean; + no_graphic?: boolean; + unused?: boolean; + priority?: boolean; + }; + export type packet_add_painting = { + entity_id_self: bigint; + runtime_entity_id: bigint; + coordinates: vec3f; + direction: number; + title: string; + }; + /** + * TickSync is sent by the client and the server to maintain a synchronized, server-authoritative tick between + * the client and the server. The client sends this packet first, and the server should reply with another one + * of these packets, including the response time. + */ + export type packet_tick_sync = { + /** ClientRequestTimestamp is the timestamp on which the client sent this packet to the server. The server should fill out that same value when replying. The ClientRequestTimestamp is always 0 */ + request_time: bigint; + /** ServerReceptionTimestamp is the timestamp on which the server received the packet sent by the client. When the packet is sent by the client, this value is 0. ServerReceptionTimestamp is generally the current tick of the server. It isn't an actual timestamp, as the field implies */ + response_time: bigint; + }; + export type packet_level_sound_event_old = { + sound_id: number; + position: vec3f; + block_id: number; + entity_type: number; + is_baby_mob: boolean; + is_global: boolean; + }; + /** + * TODO: Check and verify old versions + */ + export type packet_level_event = { + event: + | 'sound_click' + | 'sound_click_fail' + | 'sound_shoot' + | 'sound_door' + | 'sound_fizz' + | 'sound_ignite' + | 'sound_ghast' + | 'sound_ghast_shoot' + | 'sound_blaze_shoot' + | 'sound_door_bump' + | 'sound_door_crash' + | 'sound_enderman_teleport' + | 'sound_anvil_break' + | 'sound_anvil_use' + | 'sound_anvil_fall' + | 'sound_pop' + | 'sound_portal' + | 'sound_itemframe_add_item' + | 'sound_itemframe_remove' + | 'sound_itemframe_place' + | 'sound_itemframe_remove_item' + | 'sound_itemframe_rotate_item' + | 'sound_camera' + | 'sound_orb' + | 'sound_totem' + | 'sound_armor_stand_break' + | 'sound_armor_stand_hit' + | 'sound_armor_stand_fall' + | 'sound_armor_stand_place' + | 'pointed_dripstone_land' + | 'dye_used' + | 'ink_sack_used' + | 'particle_shoot' + | 'particle_destroy' + | 'particle_splash' + | 'particle_eye_despawn' + | 'particle_spawn' + | 'particle_crop_growth' + | 'particle_guardian_curse' + | 'particle_death_smoke' + | 'particle_block_force_field' + | 'particle_projectile_hit' + | 'particle_dragon_egg_teleport' + | 'particle_crop_eaten' + | 'particle_critical' + | 'particle_enderman_teleport' + | 'particle_punch_block' + | 'particle_bubble' + | 'particle_evaporate' + | 'particle_destroy_armor_stand' + | 'particle_breaking_egg' + | 'particle_destroy_egg' + | 'particle_evaporate_water' + | 'particle_destroy_block_no_sound' + | 'particle_knockback_roar' + | 'particle_teleport_trail' + | 'particle_point_cloud' + | 'particle_explosion' + | 'particle_block_explosion' + | 'particle_vibration_signal' + | 'particle_dripstone_drip' + | 'particle_fizz_effect' + | 'particle_wax_on' + | 'particle_wax_off' + | 'particle_scrape' + | 'particle_electric_spark' + | 'particle_turtle_egg' + | 'particle_sculk_shriek' + | 'sculk_catalyst_bloom' + | 'sculk_charge' + | 'sculk_charge_pop' + | 'sonic_explosion' + | 'dust_plume' + | 'start_rain' + | 'start_thunder' + | 'stop_rain' + | 'stop_thunder' + | 'pause_game' + | 'pause_game_no_screen' + | 'set_game_speed' + | 'redstone_trigger' + | 'cauldron_explode' + | 'cauldron_dye_armor' + | 'cauldron_clean_armor' + | 'cauldron_fill_potion' + | 'cauldron_take_potion' + | 'cauldron_fill_water' + | 'cauldron_take_water' + | 'cauldron_add_dye' + | 'cauldron_clean_banner' + | 'block_start_break' + | 'block_stop_break' + | 'block_break_speed' + | 'particle_punch_block_down' + | 'particle_punch_block_up' + | 'particle_punch_block_north' + | 'particle_punch_block_south' + | 'particle_punch_block_west' + | 'particle_punch_block_east' + | 'particle_shoot_white_smoke' + | 'particle_breeze_wind_explosion' + | 'particle_trial_spawner_detection' + | 'particle_trial_spawner_spawning' + | 'particle_trial_spawner_ejecting' + | 'particle_wind_explosion' + | 'particle_wolf_armor_break' + | 'ominous_item_spawner' + | 'creaking_crumble' + | 'pale_oak_leaves' + | 'eyeblossom_open' + | 'eyeblossom_close' + | 'green_flame' + | 'set_data' + | 'players_sleeping' + | 'sleeping_players' + | 'jump_prevented' + | 'animation_vault_activate' + | 'animation_vault_deactivate' + | 'animation_vault_eject_item' + | 'animation_spawn_cobweb' + | 'add_particle_smash_attack_ground_dust' + | 'add_particle_creaking_heart_trail' + | 'add_particle_mask' + | 'add_particle_bubble' + | 'add_particle_bubble_manual' + | 'add_particle_critical' + | 'add_particle_block_force_field' + | 'add_particle_smoke' + | 'add_particle_explode' + | 'add_particle_evaporation' + | 'add_particle_flame' + | 'add_particle_candle_flame' + | 'add_particle_lava' + | 'add_particle_large_smoke' + | 'add_particle_redstone' + | 'add_particle_rising_red_dust' + | 'add_particle_item_break' + | 'add_particle_snowball_poof' + | 'add_particle_huge_explode' + | 'add_particle_huge_explode_seed' + | 'add_particle_mob_flame' + | 'add_particle_heart' + | 'add_particle_terrain' + | 'add_particle_town_aura' + | 'add_particle_portal' + | 'add_particle_water_splash' + | 'add_particle_water_splash_manual' + | 'add_particle_water_wake' + | 'add_particle_drip_water' + | 'add_particle_drip_lava' + | 'add_particle_drip_honey' + | 'add_particle_stalactite_drip_water' + | 'add_particle_stalactite_drip_lava' + | 'add_particle_falling_dust' + | 'add_particle_mob_spell' + | 'add_particle_mob_spell_ambient' + | 'add_particle_mob_spell_instantaneous' + | 'add_particle_ink' + | 'add_particle_slime' + | 'add_particle_rain_splash' + | 'add_particle_villager_angry' + | 'add_particle_villager_happy' + | 'add_particle_enchantment_table' + | 'add_particle_tracking_emitter' + | 'add_particle_note' + | 'add_particle_witch_spell' + | 'add_particle_carrot' + | 'add_particle_mob_appearance' + | 'add_particle_end_rod' + | 'add_particle_dragons_breath' + | 'add_particle_spit' + | 'add_particle_totem' + | 'add_particle_food' + | 'add_particle_fireworks_starter' + | 'add_particle_fireworks_spark' + | 'add_particle_fireworks_overlay' + | 'add_particle_balloon_gas' + | 'add_particle_colored_flame' + | 'add_particle_sparkler' + | 'add_particle_conduit' + | 'add_particle_bubble_column_up' + | 'add_particle_bubble_column_down' + | 'add_particle_sneeze' + | 'add_particle_shulker_bullet' + | 'add_particle_bleach' + | 'add_particle_dragon_destroy_block' + | 'add_particle_mycelium_dust' + | 'add_particle_falling_red_dust' + | 'add_particle_campfire_smoke' + | 'add_particle_tall_campfire_smoke' + | 'add_particle_dragon_breath_fire' + | 'add_particle_dragon_breath_trail' + | 'add_particle_blue_flame' + | 'add_particle_soul' + | 'add_particle_obsidian_tear' + | 'add_particle_portal_reverse' + | 'add_particle_snowflake' + | 'add_particle_vibration_signal' + | 'add_particle_sculk_sensor_redstone' + | 'add_particle_spore_blossom_shower' + | 'add_particle_spore_blossom_ambient' + | 'add_particle_wax' + | 'add_particle_electric_spark'; + position: vec3f; + data: number; + }; + export type packet_block_event = { + /** Position is the position of the block that an event occurred at. */ + position: BlockCoordinates; + /** EventType is the type of the block event. The event type decides the way the event data that follows is used */ + type: 'sound' | 'change_state'; + /** EventData holds event type specific data. For chests for example, opening the chest means the data must be 1 */ + data: number; + }; + export type packet_entity_event = { + runtime_entity_id: bigint; + event_id: + | 'jump' + | 'hurt_animation' + | 'death_animation' + | 'arm_swing' + | 'stop_attack' + | 'tame_fail' + | 'tame_success' + | 'shake_wet' + | 'use_item' + | 'eat_grass_animation' + | 'fish_hook_bubble' + | 'fish_hook_position' + | 'fish_hook_hook' + | 'fish_hook_tease' + | 'squid_ink_cloud' + | 'zombie_villager_cure' + | 'respawn' + | 'iron_golem_offer_flower' + | 'iron_golem_withdraw_flower' + | 'love_particles' + | 'villager_angry' + | 'villager_happy' + | 'witch_spell_particles' + | 'firework_particles' + | 'in_love_particles' + | 'silverfish_spawn_animation' + | 'guardian_attack' + | 'witch_drink_potion' + | 'witch_throw_potion' + | 'minecart_tnt_prime_fuse' + | 'creeper_prime_fuse' + | 'air_supply_expired' + | 'player_add_xp_levels' + | 'elder_guardian_curse' + | 'agent_arm_swing' + | 'ender_dragon_death' + | 'dust_particles' + | 'arrow_shake' + | 'eating_item' + | 'baby_animal_feed' + | 'death_smoke_cloud' + | 'complete_trade' + | 'remove_leash' + | 'caravan' + | 'consume_totem' + | 'player_check_treasure_hunter_achievement' + | 'entity_spawn' + | 'dragon_puke' + | 'item_entity_merge' + | 'start_swim' + | 'balloon_pop' + | 'treasure_hunt' + | 'agent_summon' + | 'charged_item' + | 'fall' + | 'grow_up' + | 'vibration_detected' + | 'drink_milk' + | 'wetness_stop'; + data: number; + }; + export type packet_mob_effect = { + runtime_entity_id: bigint; + event_id: 'add' | 'update' | 'remove'; + effect_id: number; + amplifier: number; + particles: boolean; + duration: number; + tick: bigint; + ambient: boolean; + }; + export type packet_update_attributes = { + runtime_entity_id: bigint; + attributes: PlayerAttributes; + tick: bigint; + }; + /** + * InventoryTransaction is a packet sent by the client. It essentially exists out of multiple sub-packets, + * each of which have something to do with the inventory in one way or another. Some of these sub-packets + * directly relate to the inventory, others relate to interaction with the world, that could potentially + * result in a change in the inventory. + */ + export type packet_inventory_transaction = { + transaction: Transaction; + }; + export type packet_mob_equipment = { + runtime_entity_id: bigint; + item: Item; + slot: number; + selected_slot: number; + window_id: WindowID; + }; + export type packet_mob_armor_equipment = { + runtime_entity_id: bigint; + helmet: Item; + chestplate: Item; + leggings: Item; + boots: Item; + body: Item; + }; + /** + * Interact is sent by the client when it interacts with another entity in some way. It used to be used for + * normal entity and block interaction, but this is no longer the case now. + */ + export type packet_interact = { + /** Action type is the ID of the action that was executed by the player. It is one of the constants that may be found above. */ + action_id: 'leave_vehicle' | 'mouse_over_entity' | 'npc_open' | 'open_inventory'; + /** TargetEntityRuntimeID is the runtime ID of the entity that the player interacted with. This is empty for the InteractActionOpenInventory action type. */ + target_entity_id: bigint; + /** Position associated with the ActionType above. */ + has_position: boolean; + position?: vec3f; + }; + export type packet_block_pick_request = { + x: number; + y: number; + z: number; + add_user_data: boolean; + selected_slot: number; + }; + export type packet_entity_pick_request = { + runtime_entity_id: bigint; + selected_slot: number; + /** WithData is true if the pick request requests the entity metadata. */ + with_data: boolean; + }; + /** + * PlayerAction is sent by the client when it executes any action, for example starting to sprint, swim, + * starting the breaking of a block, dropping an item, etc. + */ + export type packet_player_action = { + /** EntityRuntimeID is the runtime ID of the player. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + runtime_entity_id: bigint; + /** ActionType is the ID of the action that was executed by the player. It is one of the constants that may be found above. */ + action: Action; + /** BlockPosition is the position of the target block, if the action with the ActionType set concerned a block. If that is not the case, the block position will be zero. */ + position: BlockCoordinates; + /** ResultPosition is the position of the action's result. When a UseItemOn action is sent, this is the position of the block clicked, but when a block is placed, this is the position at which the block will be placed. */ + result_position: BlockCoordinates; + /** BlockFace is the face of the target block that was touched. If the action with the ActionType set concerned a block. If not, the face is always 0. */ + face: number; + }; + export type packet_hurt_armor = { + cause: number; + damage: number; + armor_slots: bigint; + }; + export type packet_set_entity_data = { + runtime_entity_id: bigint; + metadata: MetadataDictionary; + /** EntityProperties holds lists of entity properties that define specific attributes of an entity. As of v1.19.40, the vanilla server does not use these properties, however they are still supported by the protocol. */ + properties: EntityProperties; + tick: bigint; + }; + /** + * SetActorMotion is sent by the server to change the client-side velocity of an entity. It is usually used + * in combination with server-side movement calculation. + */ + export type packet_set_entity_motion = { + /** EntityRuntimeID is the runtime ID of the entity. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + runtime_entity_id: bigint; + /** Velocity is the new velocity the entity gets. This velocity will initiate the client-side movement of the entity. */ + velocity: vec3f; + tick: bigint; + }; + /** + * SetActorLink is sent by the server to initiate an entity link client-side, meaning one entity will start + * riding another. + */ + export type packet_set_entity_link = { + link: Link; + }; + export type packet_set_health = { + health: number; + }; + export type packet_set_spawn_position = { + spawn_type: 'player' | 'world'; + player_position: BlockCoordinates; + dimension: number; + world_position: BlockCoordinates; + }; + export type packet_animate = { + action_id: 'none' | 'swing_arm' | 'unknown' | 'wake_up' | 'critical_hit' | 'magic_critical_hit'; + runtime_entity_id: bigint; + /** Data holds an action-specific value whose meaning depends on the animation type. */ + data: number; + has_swing_source: boolean; + swing_source?: string; + }; + export type packet_respawn = { + position: vec3f; + state: number; + runtime_entity_id: bigint; + }; + /** + * ContainerOpen is sent by the server to open a container client-side. This container must be physically + * present in the world, for the packet to have any effect. Unlike Java Edition, Bedrock Edition requires that + * chests for example must be present and in range to open its inventory. + */ + export type packet_container_open = { + /** WindowID is the ID representing the window that is being opened. It may be used later to close the container using a ContainerClose packet. */ + window_id: WindowID; + /** ContainerType is the type ID of the container that is being opened when opening the container at the position of the packet. It depends on the block/entity, and could, for example, be the window type of a chest or a hopper, but also a horse inventory. */ + window_type: WindowType; + /** ContainerPosition is the position of the container opened. The position must point to a block entity that actually has a container. If that is not the case, the window will not be opened and the packet will be ignored, if a valid ContainerEntityUniqueID has not also been provided. */ + coordinates: BlockCoordinates; + /** ContainerEntityUniqueID is the unique ID of the entity container that was opened. It is only used if the ContainerType is one that points to an entity, for example a horse. */ + runtime_entity_id: bigint; + }; + /** + * ContainerClose is sent by the server to close a container the player currently has opened, which was opened + * using the ContainerOpen packet, or by the client to tell the server it closed a particular container, such + * as the crafting grid. + */ + export type packet_container_close = { + /** WindowID is the ID representing the window of the container that should be closed. It must be equal to the one sent in the ContainerOpen packet to close the designated window. */ + window_id: WindowID; + /** ContainerType is the type ID of the container that is being opened when opening the container at the position of the packet. It depends on the block/entity, and could, for example, be the window type of a chest or a hopper, but also a horse inventory. */ + window_type: WindowType; + /** ServerSide determines whether or not the container was force-closed by the server. If this value is not set correctly, the client may ignore the packet and respond with a PacketViolationWarning. */ + server: boolean; + }; + /** + * PlayerHotBar is sent by the server to the client. It used to be used to link hot bar slots of the player to + * actual slots in the inventory, but as of 1.2, this was changed and hot bar slots are no longer a free + * floating part of the inventory. + * Since 1.2, the packet has been re-purposed, but its new functionality is not clear. + */ + export type packet_player_hotbar = { + selected_slot: number; + window_id: WindowID; + select_slot: boolean; + }; + /** + * InventoryContent is sent by the server to update the full content of a particular inventory. It is usually + * sent for the main inventory of the player, but also works for other inventories that are currently opened + * by the player. + */ + export type packet_inventory_content = { + /** WindowID is the ID that identifies one of the windows that the client currently has opened, or one of the consistent windows such as the main inventory. */ + window_id: WindowIDVarint; + /** Content is the new content of the inventory. The length of this slice must be equal to the full size of the inventory window updated. */ + input: ItemStacks; + /** Container is the protocol.FullContainerName that describes the container that the content is for. */ + container: FullContainerName; + /** storage_item is the item that is acting as the storage container for the inventory. If the inventory is not a dynamic container then this field should be left empty. When set, only the item type is used by the client and none of the other stack info. */ + storage_item: Item; + }; + /** + * InventorySlot is sent by the server to update a single slot in one of the inventory windows that the client + * currently has opened. Usually this is the main inventory, but it may also be the off hand or, for example, + * a chest inventory. + */ + export type packet_inventory_slot = { + /** WindowID is the ID of the window that the packet modifies. It must point to one of the windows that the client currently has opened. */ + window_id: WindowIDVarint; + /** Slot is the index of the slot that the packet modifies. The new item will be set to the slot at this index. */ + slot: number; + /** Container is the protocol.FullContainerName that describes the container that the content is for. */ + container: FullContainerName; + /** storage_item is the item that is acting as the storage container for the inventory. If the inventory is not a dynamic container then this field should be left empty. When set, only the item type is used by the client and none of the other stack info. */ + storage_item: Item; + /** NewItem is the item to be put in the slot at Slot. It will overwrite any item that may currently be present in that slot. */ + item: Item; + }; + /** + * ContainerSetData is sent by the server to update specific data of a single container, meaning a block such + * as a furnace or a brewing stand. This data is usually used by the client to display certain features + * client-side. + */ + export type packet_container_set_data = { + /** WindowID is the ID of the window that should have its data set. The player must have a window open with the window ID passed, or nothing will happen. */ + window_id: WindowID; + /** Key is the key of the property. It is one of the constants that can be found above. Multiple properties share the same key, but the functionality depends on the type of the container that the data is set to. IF FURNACE: 0: furnace_tick_count 1: furnace_lit_time 2: furnace_lit_duration 3: furnace_stored_xp 4: furnace_fuel_aux IF BREWING STAND: 0: brew_time 1: brew_fuel_amount 2: brew_fuel_total */ + property: number; + /** Value is the value of the property. Its use differs per property. */ + value: number; + }; + export type packet_crafting_data = { + recipes: Recipes; + /** PotionContainerChangeRecipes is a list of all recipes to convert a potion from one type to another, such as from a drinkable potion to a splash potion, or from a splash potion to a lingering potion. */ + potion_type_recipes: PotionTypeRecipes; + potion_container_recipes: PotionContainerChangeRecipes; + /** MaterialReducers is a list of all material reducers which is used in education edition chemistry. */ + material_reducers: MaterialReducer[]; + /** ClearRecipes indicates if all recipes currently active on the client should be cleaned. Doing this means that the client will have no recipes active by itself: Any CraftingData packets previously sent will also be discarded, and only the recipes in this CraftingData packet will be used. */ + clear_recipes: boolean; + }; + /** + * CraftingEvent is sent by the client when it crafts a particular item. Note that this packet may be fully + * ignored, as the InventoryTransaction packet provides all the information required. + */ + export type packet_crafting_event = { + /** WindowID is the ID representing the window that the player crafted in. */ + window_id: WindowID; + /** CraftingType is a type that indicates the way the crafting was done, for example if a crafting table was used. */ + recipe_type: 'inventory' | 'crafting' | 'workbench'; + /** RecipeUUID is the UUID of the recipe that was crafted. It points to the UUID of the recipe that was sent earlier in the CraftingData packet. */ + recipe_id: string; + /** Input is a list of items that the player put into the recipe so that it could create the Output items. These items are consumed in the process. */ + input: Item[]; + /** Output is a list of items that were obtained as a result of crafting the recipe. */ + result: Item[]; + }; + /** + * GUIDataPickItem is sent by the server to make the client 'select' a hot bar slot. It currently appears to + * be broken however, and does not actually set the selected slot to the hot bar slot set in the packet. + */ + export type packet_gui_data_pick_item = { + /** ItemName is the name of the item that shows up in the top part of the popup that shows up when selecting an item. It is shown as if an item was selected by the player itself. */ + item_name: string; + /** ItemEffects is the line under the ItemName, where the effects of the item are usually situated. */ + item_effects: string; + /** HotBarSlot is the hot bar slot to be selected/picked. This does not currently work, so it does not matter what number this is. */ + hotbar_slot: number; + }; + /** + * AdventureSettings is sent by the server to update game-play related features, in particular permissions to + * access these features for the client. It includes allowing the player to fly, build and mine, and attack + * entities. Most of these flags should be checked server-side instead of using this packet only. + * The client may also send this packet to the server when it updates one of these settings through the + * in-game settings interface. The server should verify if the player actually has permission to update those + * settings. + */ + export type packet_adventure_settings = { + /** Flags is a set of flags that specify certain properties of the player, such as whether or not it can fly and/or move through blocks. It is one of the AdventureFlag constants above. */ + flags: AdventureFlags; + /** CommandPermissionLevel is a permission level that specifies the kind of commands that the player is allowed to use. */ + command_permission: CommandPermissionLevelVarint; + /** ActionPermissions is, much like Flags, a set of flags that specify actions that the player is allowed to undertake, such as whether it is allowed to edit blocks, open doors etc. It is a combination of the ActionPermission constants above. */ + action_permissions: ActionPermissions; + /** PermissionLevel is the permission level of the player as it shows up in the player list built up using the PlayerList packet. It is one of the PermissionLevel constants above. */ + permission_level: PermissionLevel; + /** Custom permissions */ + custom_stored_permissions: number; + /** PlayerUniqueID is a unique identifier of the player. It appears it is not required to fill this field out with a correct value. Simply writing 0 seems to work. */ + user_id: bigint; + }; + export type AdventureFlags = { + world_immutable?: boolean; + no_pvp?: boolean; + auto_jump?: boolean; + allow_flight?: boolean; + no_clip?: boolean; + world_builder?: boolean; + flying?: boolean; + muted?: boolean; + }; + export type ActionPermissions = { + mine?: boolean; + doors_and_switches?: boolean; + open_containers?: boolean; + attack_players?: boolean; + attack_mobs?: boolean; + operator?: boolean; + teleport?: boolean; + build?: boolean; + default?: boolean; + }; + export type packet_block_entity_data = { + position: BlockCoordinates; + nbt: any; + }; + /** + * Removed in 1.21.80 + */ + export type packet_player_input = { + motion_x: number; + motion_z: number; + jumping: boolean; + sneaking: boolean; + }; + /** + * LevelChunk is sent by the server to provide the client with a chunk of a world data (16xYx16 blocks). + * Typically a certain amount of chunks is sent to the client before sending it the spawn PlayStatus packet, + * so that the client spawns in a loaded world. + */ + export type packet_level_chunk = { + /** ChunkX is the X coordinate of the chunk sent. (To translate a block's X to a chunk's X: x >> 4) */ + x: number; + /** ChunkZ is the Z coordinate of the chunk sent. (To translate a block's Z to a chunk's Z: z >> 4) */ + z: number; + dimension: number; + /** SubChunkCount is the amount of sub chunks that are part of the chunk sent. Depending on if the cache is enabled, a list of blob hashes will be sent, or, if disabled, the sub chunk data. On newer versions, if this is a negative value it indicates to use the Subchunk Polling mechanism */ + sub_chunk_count: number; + /** HighestSubChunk is the highest sub-chunk at the position that is not all air. It is only set if the RequestMode is set to protocol.SubChunkRequestModeLimited. */ + highest_subchunk_count?: number; + /** CacheEnabled specifies if the client blob cache should be enabled. This system is based on hashes of blobs which are consistent and saved by the client in combination with that blob, so that the server does not have to send the same chunk multiple times. If the client does not yet have a blob with the hash sent, it will send a ClientCacheBlobStatus packet containing the hashes is does not have the data of. */ + cache_enabled: boolean; + blobs?: { + hashes: bigint[]; + }; + payload: ByteArray; + }; + export type packet_set_commands_enabled = { + enabled: boolean; + }; + export type packet_set_difficulty = { + difficulty: number; + }; + export type packet_change_dimension = { + dimension: number; + position: vec3f; + respawn: boolean; + loading_screen_id?: number; + }; + /** + * SetPlayerGameType is sent by the server to update the game type (game mode) of the player + */ + export type packet_set_player_game_type = { + /** The new gamemode for the player. Some of these game types require additional flags to be set in an AdventureSettings packet for the game mode to obtain its full functionality. # Note: this is actually encoded 64-bit varint, but realistically won't exceed a few bits */ + gamemode: GameMode; + }; + export type packet_player_list = { + records: PlayerRecords; + }; + export type packet_simple_event = { + event_type: 'uninitialized_subtype' | 'enable_commands' | 'disable_commands' | 'unlock_world_template_settings'; + }; + /** + * Event is sent by the server to send an event with additional data. It is typically sent to the client for + * telemetry reasons, much like the SimpleEvent packet. + */ + export type packet_event = { + runtime_id: bigint; + event_type: + | 'achievement_awarded' + | 'entity_interact' + | 'portal_built' + | 'portal_used' + | 'mob_killed' + | 'cauldron_used' + | 'player_death' + | 'boss_killed' + | 'agent_command' + | 'agent_created' + | 'banner_pattern_removed' + | 'command_executed' + | 'fish_bucketed' + | 'mob_born' + | 'pet_died' + | 'cauldron_block_used' + | 'composter_block_used' + | 'bell_block_used' + | 'actor_definition' + | 'raid_update' + | 'player_movement_anomaly' + | 'player_movement_corrected' + | 'honey_harvested' + | 'target_block_hit' + | 'piglin_barter' + | 'waxed_or_unwaxed_copper' + | 'code_builder_runtime_action' + | 'code_builder_scoreboard' + | 'strider_ridden_in_lava_in_overworld' + | 'sneak_close_to_sculk_sensor' + | 'careful_restoration' + | 'item_used'; + use_player_id: number; + event_data: Buffer; + }; + export type packet_spawn_experience_orb = { + position: vec3f; + count: number; + }; + export type UpdateMapFlags = { + void?: boolean; + texture?: boolean; + decoration?: boolean; + initialisation?: boolean; + }; + /** + * ClientBoundMapItemData is sent by the server to the client to update the data of a map shown to the client. + * It is sent with a combination of flags that specify what data is updated. + * The ClientBoundMapItemData packet may be used to update specific parts of the map only. It is not required + * to send the entire map each time when updating one part. + */ + export type packet_clientbound_map_item_data = { + /** MapID is the unique identifier that represents the map that is updated over network. It remains consistent across sessions. */ + map_id: bigint; + /** UpdateFlags is a combination of flags found above that indicate what parts of the map should be updated client-side. */ + update_flags: UpdateMapFlags; + /** Dimension is the dimension of the map that should be updated, for example the overworld (0), the nether (1) or the end (2). */ + dimension: number; + /** LockedMap specifies if the map that was updated was a locked map, which may be done using a cartography table. */ + locked: boolean; + /** Origin is the center position of the map being updated. */ + origin: vec3i; + /** The following fields apply only for the MapUpdateFlagInitialisation. MapsIncludedIn holds an array of map IDs that the map updated is included in. This has to do with the scale of the map: Each map holds its own map ID and all map IDs of maps that include this map and have a bigger scale. This means that a scale 0 map will have 5 map IDs in this slice, whereas a scale 4 map will have only 1 (its own). The actual use of this field remains unknown. */ + included_in?: bigint[]; + /** Scale is the scale of the map as it is shown in-game. It is written when any of the MapUpdateFlags are set to the UpdateFlags field. */ + scale?: number; + /** The following fields apply only for the MapUpdateFlagDecoration. TrackedObjects is a list of tracked objects on the map, which may either be entities or blocks. The client makes sure these tracked objects are actually tracked. (position updated etc.) */ + tracked?: { + objects: TrackedObject[]; + decorations: MapDecoration[]; + }; + texture?: { + width: number; + height: number; + x_offset: number; + y_offset: number; + pixels: number[]; + }; + }; + export type packet_map_info_request = { + map_id: bigint; + /** ClientPixels is a map of pixels sent from the client to notify the server about the pixels that it isn't aware of. */ + client_pixels: { + rgba: number; + index: number; + }[]; + }; + /** + * RequestChunkRadius is sent by the client to the server to update the server on the chunk view radius that + * it has set in the settings. The server may respond with a ChunkRadiusUpdated packet with either the chunk + * radius requested, or a different chunk radius if the server chooses so. + */ + export type packet_request_chunk_radius = { + /** ChunkRadius is the requested chunk radius. This value is always the value set in the settings of the player. */ + chunk_radius: number; + max_radius: number; + }; + /** + * ChunkRadiusUpdated is sent by the server in response to a RequestChunkRadius packet. It defines the chunk + * radius that the server allows the client to have. This may be lower than the chunk radius requested by the + * client in the RequestChunkRadius packet. + */ + export type packet_chunk_radius_update = { + /** ChunkRadius is the final chunk radius that the client will adapt when it receives the packet. It does not have to be the same as the requested chunk radius. */ + chunk_radius: number; + }; + export type packet_game_rules_changed = { + rules: GameRuleI32[]; + }; + /** + * Camera is sent by the server to use an Education Edition camera on a player. It produces an image + * client-side. + */ + export type packet_camera = { + /** CameraEntityUniqueID is the unique ID of the camera entity from which the picture was taken. */ + camera_entity_unique_id: bigint; + /** TargetPlayerUniqueID is the unique ID of the target player. The unique ID is a value that remains consistent across different sessions of the same world, but most servers simply fill the runtime ID of the player out for this field. */ + target_player_unique_id: bigint; + }; + export type packet_boss_event = { + boss_entity_id: bigint; + type: 'show_bar' | 'register_player' | 'hide_bar' | 'unregister_player' | 'set_bar_progress' | 'set_bar_title' | 'update_properties' | 'texture' | 'query'; + title?: string; + filtered_title?: string; + progress?: number; + screen_darkening?: number; + color?: number; + overlay?: number; + player_id?: bigint; + }; + export type packet_show_credits = { + runtime_entity_id: bigint; + status: number; + }; + /** + * This packet sends a list of commands to the client. Commands can have + * arguments, and some of those arguments can have 'enum' values, which are a list of possible + * values for the argument. The serialization is rather complex and involves palettes like chunks. + * # In bedrock-protocol, listen to on('client.commands') for a simpler representation + */ + export type packet_available_commands = { + /** The length of the enums for all the command parameters in this packet */ + values_len: number; + /** Here all the enum values for all of the possible commands are stored to one array palette */ + enum_values: string[]; + /** chained_subcommand_values is a slice of all chained subcommand names. chained_subcommand_values generally should contain each possible value only once. chained_subcommands are built by pointing to entries in this slice. */ + chained_subcommand_values: string[]; + /** Integer parameters may sometimes have a prefix, such as the XP command: /xp [player: target] <- here, the xp command gives experience points /xp L [player: target] <- here, the xp command gives experience levels This is the palette of suffixes */ + suffixes: string[]; + /** The list of enum objects */ + enums: { + /** The name of the enum */ + name: string; + /** The values in the enum. In 1.21.130 this is always lu32. */ + values: number[]; + }[]; + /** chained_subcommands is a slice of all subcommands that are followed by a chained command. An example usage of this is /execute which allows you to run another command as another entity or at a different position etc. */ + chained_subcommands: { + /** ChainedSubcommandValue represents the value for a chained subcommand argument. name is the name of the chained subcommand and shows up in the list as a regular subcommand enum. */ + name: string; + /** values contains the index and parameter type of the chained subcommand. */ + values: { + /** index is the index of the argument in the ChainedSubcommandValues slice. In 1.21.130 this changed from lu16 to varint. */ + index: number; + /** value is a combination of the flags above. In 1.21.130 this changed from lu16 to varint. */ + value: number; + }[]; + }[]; + command_data: { + name: string; + description: string; + flags: number; + permission_level: string; + alias: number; + chained_subcommand_offsets: number[]; + overloads: { + chaining: boolean; + parameters: { + parameter_name: string; + value_type: + | 'int' + | 'float' + | 'value' + | 'wildcard_int' + | 'operator' + | 'command_operator' + | 'target' + | 'wildcard_target' + | 'file_path' + | 'integer_range' + | 'equipment_slots' + | 'string' + | 'block_position' + | 'position' + | 'message' + | 'raw_text' + | 'json' + | 'block_states' + | 'command'; + enum_type: 'valid' | 'enum' | 'suffixed' | 'soft_enum'; + optional: boolean; + options: CommandFlags; + }[]; + }[]; + }[]; + dynamic_enums: { + name: string; + values: string[]; + }[]; + enum_constraints: { + value_index: number; + enum_index: number; + constraints: { + constraint: 'cheats_enabled' | 'operator_permissions' | 'host_permissions'; + }[]; + }[]; + }; + /** + * ParamOptionCollapseEnum specifies if the enum (only if the Type is actually an enum type. If not, + * setting this to true has no effect) should be collapsed. This means that the options of the enum are + * never shown in the actual usage of the command, but only as auto-completion, like it automatically does + * with enums that have a big amount of options. To illustrate, it can make + * <$Name: bool>. + */ + export type CommandFlags = { + unused?: boolean; + collapse_enum?: boolean; + has_semantic_constraint?: boolean; + as_chained_command?: boolean; + unknown2?: boolean; + }; + /** + * enum_size_based_on_values_len: native + * CommandRequest is sent by the client to request the execution of a server-side command. Although some + * servers support sending commands using the Text packet, this packet is guaranteed to have the correct + * result. + */ + export type packet_command_request = { + /** CommandLine is the raw entered command line. The client does no parsing of the command line by itself (unlike it did in the early stages), but lets the server do that. */ + command: string; + /** Origin holds information about the command sender that will be returnd back in the command response */ + origin: CommandOrigin; + /** Internal specifies if the command request internal. Setting it to false seems to work and the usage of this field is not known. */ + internal: boolean; + /** Specifies the version of the command to run, relative to the current Minecraft version. Should be set to 52 as of 1.19.62 */ + version: string; + }; + /** + * CommandBlockUpdate is sent by the client to update a command block at a specific position. The command + * block may be either a physical block or an entity. + */ + export type packet_command_block_update = { + /** Block specifies if the command block updated was an actual physical block. If false, the command block is in a minecart and has an entity runtime ID instead. */ + is_block: boolean; + position?: BlockCoordinates; + mode?: 'impulse' | 'repeat' | 'chain'; + needs_redstone?: boolean; + conditional?: boolean; + minecart_entity_runtime_id?: bigint; + command: string; + last_output: string; + name: string; + filtered_name: string; + should_track_output: boolean; + tick_delay: number; + execute_on_first_tick: boolean; + }; + export type packet_command_output = { + /** CommandOrigin is the data specifying the origin of the command. In other words, the source that the command request was from, such as the player itself or a websocket server. The client forwards the messages in this packet to the right origin, depending on what is sent here. */ + origin: CommandOrigin; + /** OutputType specifies the type of output that is sent. OutputType specifies the type of output that is sent. */ + output_type: string; + /** SuccessCount is the amount of times that a command was executed successfully as a result of the command that was requested. For servers, this is usually a rather meaningless fields, but for vanilla, this is applicable for commands created with Functions. */ + success_count: number; + /** OutputMessages is a list of all output messages that should be sent to the player. Whether they are shown or not, depends on the type of the messages. */ + output: { + /** Message is the message that is sent to the client in the chat window. */ + message_id: string; + /** Success indicates if the output message was one of a successful command execution. */ + success: boolean; + /** Parameters is a list of parameters that serve to supply the message sent with additional information, such as the position that a player was teleported to or the effect that was applied to an entity. These parameters only apply for the Minecraft built-in command output. */ + parameters: string[]; + }[]; + has_data: boolean; + data?: string; + }; + /** + * UpdateTrade is sent by the server to update the trades offered by a villager to a player. It is sent at the + * moment that a player interacts with a villager. + */ + export type packet_update_trade = { + /** WindowID is the ID that identifies the trading window that the client currently has opened. */ + window_id: WindowID; + /** WindowType is an identifier specifying the type of the window opened. In vanilla, it appears this is always filled out with 15. */ + window_type: WindowType; + /** Size is the amount of trading options that the villager has. */ + size: number; + /** TradeTier is the tier of the villager that the player is trading with. The tier starts at 0 with a first two offers being available, after which two additional offers are unlocked each time the tier becomes one higher. */ + trade_tier: number; + /** VillagerUniqueID is the unique ID of the villager entity that the player is trading with. The TradeTier sent above applies to this villager. */ + villager_unique_id: bigint; + /** EntityUniqueID is the unique ID of the entity (usually a player) for which the trades are updated. The updated trades may apply only to this entity. */ + entity_unique_id: bigint; + /** DisplayName is the name displayed at the top of the trading UI. It is usually used to represent the profession of the villager in the UI. */ + display_name: string; + /** NewTradeUI specifies if the villager should be using the new trade UI (The one added in 1.11.) rather than the old one. This should usually be set to true. */ + new_trading_ui: boolean; + /** Trading based on Minecraft economy - specifies if the prices of the villager's offers are modified by an increase in demand for the item. (A mechanic added in 1.11.) Buying more of the same item will increase the price of that particular item. https://minecraft.wiki/w/Trading#Economics */ + economic_trades: boolean; + /** NBT serialised compound of offers that the villager has. */ + offers: any; + }; + /** + * UpdateEquip is sent by the server to the client upon opening a horse inventory. It is used to set the + * content of the inventory and specify additional properties, such as the items that are allowed to be put + * in slots of the inventory. + */ + export type packet_update_equipment = { + /** WindowID is the identifier associated with the window that the UpdateEquip packet concerns. It is the ID sent for the horse inventory that was opened before this packet was sent. */ + window_id: WindowID; + /** WindowType is the type of the window that was opened. Generally, this is the type of a horse inventory, as the packet is specifically made for that. */ + window_type: WindowType; + /** Size is the size of the horse inventory that should be opened. A bigger size does, in fact, change the amount of slots displayed. */ + size: number; + /** EntityUniqueID is the unique ID of the entity whose equipment was 'updated' to the player. It is typically the horse entity that had its inventory opened. */ + entity_id: bigint; + /** `inventory` is a network NBT serialised compound holding the content of the inventory of the entity (the equipment) and additional data such as the allowed items for a particular slot, used to make sure only saddles can be put in the saddle slot etc. */ + inventory: any; + }; + /** + * ResourcePackDataInfo is sent by the server to the client to inform the client about the data contained in + * one of the resource packs that are about to be sent. + */ + export type packet_resource_pack_data_info = { + /** UUID is the unique ID of the resource pack that the info concerns. */ + pack_id: string; + /** DataChunkSize is the maximum size in bytes of the chunks in which the total size of the resource pack to be sent will be divided. A size of 1MB (1024*1024) means that a resource pack of 15.5MB will be split into 16 data chunks. */ + max_chunk_size: number; + /** ChunkCount is the total amount of data chunks that the sent resource pack will exist out of. It is the total size of the resource pack divided by the DataChunkSize field. The client doesn't actually seem to use this field. Rather, it divides the size by the chunk size to calculate it itself. */ + chunk_count: number; + /** Size is the total size in bytes that the resource pack occupies. This is the size of the compressed archive (zip) of the resource pack. */ + size: bigint; + /** Hash is a SHA256 hash of the content of the resource pack. */ + hash: ByteArray; + /** Premium specifies if the resource pack was a premium resource pack, meaning it was bought from the Minecraft store. */ + is_premium: boolean; + /** PackType is the type of the resource pack. It is one of the resource pack types listed. */ + pack_type: 'addon' | 'cached' | 'copy_protected' | 'behavior' | 'persona_piece' | 'resources' | 'skins' | 'world_template'; + }; + /** + * ResourcePackChunkData is sent to the client so that the client can download the resource pack. Each packet + * holds a chunk of the compressed resource pack, of which the size is defined in the ResourcePackDataInfo + * packet sent before. + */ + export type packet_resource_pack_chunk_data = { + /** UUID is the unique ID of the resource pack that the chunk of data is taken out of. */ + pack_id: string; + /** ChunkIndex is the current chunk index of the chunk. It is a number that starts at 0 and is incremented for each resource pack data chunk sent to the client. */ + chunk_index: number; + /** DataOffset is the current progress in bytes or offset in the data that the resource pack data chunk is taken from. */ + progress: bigint; + /** RawPayload is a byte slice containing a chunk of data from the resource pack. It must be of the same size or less than the DataChunkSize set in the ResourcePackDataInfo packet. */ + payload: ByteArray; + }; + /** + * ResourcePackChunkRequest is sent by the client to request a chunk of data from a particular resource pack, + * that it has obtained information about in a ResourcePackDataInfo packet. + */ + export type packet_resource_pack_chunk_request = { + /** UUID is the unique ID of the resource pack that the chunk of data is requested from. */ + pack_id: string; + /** ChunkIndex is the requested chunk index of the chunk. It is a number that starts at 0 and is incremented for each resource pack data chunk requested. */ + chunk_index: number; + }; + export type packet_transfer = { + server_address: string; + port: number; + reload_world: boolean; + }; + export type packet_play_sound = { + name: string; + coordinates: BlockCoordinates; + volume: number; + pitch: number; + }; + export type packet_stop_sound = { + name: string; + stop_all: boolean; + stop_music_legacy: boolean; + }; + /** + * SetTitle is sent by the server to make a title, subtitle or action bar shown to a player. It has several + * fields that allow setting the duration of the titles. + */ + export type packet_set_title = { + /** ActionType is the type of the action that should be executed upon the title of a player. It is one of the constants above and specifies the response of the client to the packet. */ + type: 'clear' | 'reset' | 'set_title' | 'set_subtitle' | 'action_bar_message' | 'set_durations' | 'set_title_json' | 'set_subtitle_json' | 'action_bar_message_json'; + /** Text is the text of the title, which has a different meaning depending on the ActionType that the packet has. The text is the text of a title, subtitle or action bar, depending on the type set. */ + text: string; + /** FadeInDuration is the duration that the title takes to fade in on the screen of the player. It is measured in 20ths of a second (AKA in ticks). */ + fade_in_time: number; + /** RemainDuration is the duration that the title remains on the screen of the player. It is measured in 20ths of a second (AKA in ticks). */ + stay_time: number; + /** FadeOutDuration is the duration that the title takes to fade out of the screen of the player. It is measured in 20ths of a second (AKA in ticks). */ + fade_out_time: number; + /** XUID is the XBOX Live user ID of the player, which will remain consistent as long as the player is logged in with the XBOX Live account. It is empty if the user is not logged into its XBL account. */ + xuid: string; + /** PlatformOnlineID is either a uint64 or an empty string. */ + platform_online_id: string; + /** FilteredMessage is a filtered version of Message with all the profanity removed. The client will use this over Message if this field is not empty and they have the "Filter Profanity" setting enabled. */ + filtered_message: string; + }; + export type packet_add_behavior_tree = { + behaviortree: string; + }; + /** + * StructureBlockUpdate is sent by the client when it updates a structure block using the in-game UI. The + * data it contains depends on the type of structure block that it is. In Minecraft Bedrock Edition v1.11, + * there is only the Export structure block type, but in v1.13 the ones present in Java Edition will, + * according to the wiki, be added too. + */ + export type packet_structure_block_update = { + /** Position is the position of the structure block that is updated. */ + position: BlockCoordinates; + /** StructureName is the name of the structure that was set in the structure block's UI. This is the name used to export the structure to a file. */ + structure_name: string; + /** FilteredStructureName is a filtered version of StructureName with all the profanity removed. The client will use this over StructureName if this field is not empty and they have the "Filter Profanity" setting enabled. */ + filtered_structure_name: string; + /** DataField is the name of a function to run, usually used during natural generation. A description can be found here: https://minecraft.wiki/w/Structure_Block#Data. */ + data_field: string; + /** IncludePlayers specifies if the 'Include Players' toggle has been enabled, meaning players are also exported by the structure block. */ + include_players: boolean; + /** ShowBoundingBox specifies if the structure block should have its bounds outlined. A thin line will encapsulate the bounds of the structure if set to true. */ + show_bounding_box: boolean; + /** StructureBlockType is the type of the structure block updated. A list of structure block types that will be used can be found in the constants above. */ + structure_block_type: number; + /** Settings is a struct of settings that should be used for exporting the structure. These settings are identical to the last sent in the StructureBlockUpdate packet by the client. */ + settings: StructureBlockSettings; + /** RedstoneSaveMode is the mode that should be used to save the structure when used with redstone. In Java Edition, this is always stored in memory, but in Bedrock Edition it can be stored either to disk or memory. See the constants above for the options. */ + redstone_save_mode: number; + /** ShouldTrigger specifies if the structure block should be triggered immediately after this packet reaches the server. */ + should_trigger: boolean; + /** Waterlogged specifies if the structure block is waterlogged at the time of the packet being sent. */ + water_logged: boolean; + }; + /** + * ShowStoreOffer is sent by the server to show a Marketplace store offer to a player. It opens a window + * client-side that displays the item. + * The ShowStoreOffer packet only works on the partnered servers: Servers that are not partnered will not have + * a store buttons show up in the in-game pause menu and will, as a result, not be able to open store offers + * on the client side. Sending the packet does therefore not work when using a proxy that is not connected to + * with the domain of one of the partnered servers. + */ + export type packet_show_store_offer = { + /** OfferUUID is a UUID that identifies the offer for which a window should be opened. */ + offer_uuid: string; + /** ShowAll specifies if all other offers of the same 'author' as the one of the offer associated with the OfferUUID should also be displayed, alongside the target offer. */ + redirect_type: 'marketplace' | 'dressing_room' | 'third_party_server_page'; + }; + /** + * PurchaseReceipt is sent by the client to the server to notify the server it purchased an item from the + * Marketplace store that was offered by the server. The packet is only used for partnered servers. + */ + export type packet_purchase_receipt = { + /** Receipts is a list of receipts, or proofs of purchases, for the offers that have been purchased by the player. */ + receipts: string[]; + }; + export type packet_player_skin = { + uuid: string; + skin: Skin; + skin_name: string; + old_skin_name: string; + is_verified: boolean; + }; + /** + * SubClientLogin is sent when a sub-client joins the server while another client is already connected to it. + * The packet is sent as a result of split-screen game play, and allows up to four players to play using the + * same network connection. After an initial Login packet from the 'main' client, each sub-client that + * connects sends a SubClientLogin to request their own login. + */ + export type packet_sub_client_login = { + /** ConnectionRequest is a string containing information about the player and JWTs that may be used to verify if the player is connected to XBOX Live. The connection request also contains the necessary client public key to initiate encryption. The ConnectionRequest in this packet is identical to the one found in the Login packet. */ + tokens: LoginTokens; + }; + /** + * AutomationClientConnect is used to make the client connect to a websocket server. This websocket server has + * the ability to execute commands on the behalf of the client and it can listen for certain events fired by + * the client. + */ + export type packet_initiate_web_socket_connection = { + /** ServerURI is the URI to make the client connect to. It can be, for example, 'localhost:8000/ws' to connect to a websocket server on the localhost at port 8000. */ + server: string; + }; + /** + * SetLastHurtBy is sent by the server to let the client know what entity type it was last hurt by. At this + * moment, the packet is useless and should not be used. There is no behaviour that depends on if this + * packet is sent or not. + */ + export type packet_set_last_hurt_by = { + entity_type: number; + }; + /** + * BookEdit is sent by the client when it edits a book. It is sent each time a modification was made and the + * player stops its typing 'session', rather than simply after closing the book. + */ + export type packet_book_edit = { + type: 'replace_page' | 'add_page' | 'delete_page' | 'swap_pages' | 'sign'; + slot: number; + page_number?: number; + text?: string; + photo_name?: string; + page1?: number; + page2?: number; + title?: string; + author?: string; + xuid?: string; + }; + /** + * NPCRequest is sent by the client when it interacts with an NPC. + * The packet is specifically made for Education Edition, where NPCs are available to use. + */ + export type packet_npc_request = { + /** EntityRuntimeID is the runtime ID of the NPC entity that the player interacted with. It is the same as sent by the server when spawning the entity. */ + runtime_entity_id: bigint; + /** RequestType is the type of the request, which depends on the permission that the player has. It will be either a type that indicates that the NPC should show its dialog, or that it should open the editing window. */ + request_type: 'set_actions' | 'execute_action' | 'execute_closing_commands' | 'set_name' | 'set_skin' | 'set_interaction_text' | 'execute_opening_commands'; + /** CommandString is the command string set in the NPC. It may consist of multiple commands, depending on what the player set in it. */ + command: string; + /** ActionType is the type of the action to execute. */ + action_type: 'set_actions' | 'execute_action' | 'execute_closing_commands' | 'set_name' | 'set_skin' | 'set_interact_text' | 'execute_opening_commands'; + /** SceneName is the name of the scene. */ + scene_name: string; + }; + /** + * PhotoTransfer is sent by the server to transfer a photo (image) file to the client. It is typically used + * to transfer photos so that the client can display it in a portfolio in Education Edition. + * While previously usable in the default Bedrock Edition, the displaying of photos in books was disabled and + * the packet now has little use anymore. + */ + export type packet_photo_transfer = { + /** PhotoName is the name of the photo to transfer. It is the exact file name that the client will download the photo as, including the extension of the file. */ + image_name: string; + /** PhotoData is the raw data of the photo image. The format of this data may vary: Formats such as JPEG or PNG work, as long as PhotoName has the correct extension. */ + image_data: string; + /** BookID is the ID of the book that the photo is associated with. If the PhotoName in a book with this ID is set to PhotoName, it will display the photo (provided Education Edition is used). The photo image is downloaded to a sub-folder with this book ID. */ + book_id: string; + /** PhotoType is one of the three photo types above. */ + photo_type: number; + /** SourceType is the source photo type. It is one of the three photo types above. */ + source_type: number; + /** OwnerEntityUniqueID is the entity unique ID of the photo's owner. */ + owner_entity_unique_id: bigint; + /** NewPhotoName is the new name of the photo. */ + new_photo_name: string; + }; + /** + * ModalFormRequest is sent by the server to make the client open a form. This form may be either a modal form + * which has two options, a menu form for a selection of options and a custom form for properties. + */ + export type packet_modal_form_request = { + /** FormID is an ID used to identify the form. The ID is saved by the client and sent back when the player submits the form, so that the server can identify which form was submitted. */ + form_id: number; + /** FormData is a JSON encoded object of form data. The content of the object differs, depending on the type of the form sent, which is also set in the JSON. */ + data: string; + }; + /** + * ModalFormResponse is sent by the client in response to a ModalFormRequest, after the player has submitted + * the form sent. It contains the options/properties selected by the player, or a JSON encoded 'null' if + * the form was closed by clicking the X at the top right corner of the form. + */ + export type packet_modal_form_response = { + /** FormID is the form ID of the form the client has responded to. It is the same as the ID sent in the ModalFormRequest, and may be used to identify which form was submitted. */ + form_id: number; + /** HasResponseData is true if the client provided response data. */ + has_response_data: boolean; + /** ResponseData is a JSON encoded value representing the response of the player. For a modal form, the response is either true or false, for a menu form, the response is an integer specifying the index of the button clicked, and for a custom form, the response is an array containing a value for each element. */ + data?: string; + /** HasCancelReason is true if the client provided a reason for the form being cancelled. */ + has_cancel_reason: boolean; + cancel_reason?: 'closed' | 'busy'; + }; + /** + * ServerSettingsRequest is sent by the client to request the settings specific to the server. These settings + * are shown in a separate tab client-side, and have the same structure as a custom form. + * ServerSettingsRequest has no fields. + */ + export type packet_server_settings_request = {}; + export type packet_server_settings_response = { + form_id: number; + data: string; + }; + export type packet_show_profile = { + xuid: string; + }; + export type packet_set_default_game_type = { + gamemode: GameMode; + }; + export type packet_remove_objective = { + objective_name: string; + }; + export type packet_set_display_objective = { + display_slot: string; + objective_name: string; + display_name: string; + criteria_name: string; + sort_order: number; + }; + export type packet_set_score = { + action: 'change' | 'remove'; + entries: { + scoreboard_id: bigint; + objective_name: string; + score: number; + entry_type?: 'player' | 'entity' | 'fake_player'; + entity_unique_id?: bigint | undefined; + custom_name?: string | undefined; + }[]; + }; + /** + * LabTable is sent by the client to let the server know it started a chemical reaction in Education Edition, + * and is sent by the server to other clients to show the effects. + * The packet is only functional if Education features are enabled. + */ + export type packet_lab_table = { + /** ActionType is the type of the action that was executed. It is one of the constants above. Typically, only LabTableActionCombine is sent by the client, whereas LabTableActionReact is sent by the server. */ + action_type: 'combine' | 'react' | 'reset'; + /** Position is the position at which the lab table used was located. */ + position: vec3i; + /** ReactionType is the type of the reaction that took place as a result of the items put into the lab table. The reaction type can be either that of an item or a particle, depending on whatever the result was of the reaction. */ + reaction_type: number; + }; + /** + * UpdateBlockSynced is sent by the server to synchronise the falling of a falling block entity with the + * transitioning back and forth from and to a solid block. It is used to prevent the entity from flickering, + * and is used in places such as the pushing of blocks with pistons. + */ + export type packet_update_block_synced = { + /** Position is the block position at which a block is updated. */ + position: BlockCoordinates; + /** NewBlockRuntimeID is the runtime ID of the block that is placed at Position after sending the packet to the client. */ + block_runtime_id: number; + /** Flags is a combination of flags that specify the way the block is updated client-side. It is a combination of the flags above, but typically sending only the BlockUpdateNetwork flag is sufficient. */ + flags: UpdateBlockFlags; + /** Layer is the world layer on which the block is updated. For most blocks, this is the first layer, as that layer is the default layer to place blocks on, but for blocks inside of each other, this differs. */ + layer: number; + /** EntityUniqueID is the unique ID of the falling block entity that the block transitions to or that the entity transitions from. Note that for both possible values for TransitionType, the EntityUniqueID should point to the falling block entity involved. */ + entity_unique_id: bigint; + /** TransitionType is the type of the transition that happened. It is either BlockToEntityTransition, when a block placed becomes a falling entity, or EntityToBlockTransition, when a falling entity hits the ground and becomes a solid block again. */ + transition_type: 'entity' | 'create' | 'destroy'; + }; + /** + * MoveActorDelta is sent by the server to move an entity. The packet is specifically optimised to save as + * much space as possible, by only writing non-zero fields. + * As of 1.16.100, this packet no longer actually contains any deltas. + */ + export type packet_move_entity_delta = { + /** EntityRuntimeID is the runtime ID of the entity that is being moved. The packet works provided a non-player entity with this runtime ID is present. */ + runtime_entity_id: bigint; + /** Flags is a list of flags that specify what data is in the packet. */ + flags: DeltaMoveFlags; + x?: number; + y?: number; + z?: number; + rot_x?: number; + rot_y?: number; + rot_z?: number; + }; + export type DeltaMoveFlags = { + has_x?: boolean; + has_y?: boolean; + has_z?: boolean; + has_rot_x?: boolean; + has_rot_y?: boolean; + has_rot_z?: boolean; + on_ground?: boolean; + teleport?: boolean; + force_move?: boolean; + }; + /** + * SetScoreboardIdentity is sent by the server to change the identity type of one of the entries on a + * scoreboard. This is used to change, for example, an entry pointing to a player, to a fake player when it + * leaves the server, and to change it back to a real player when it joins again. + * In non-vanilla situations, the packet is quite useless. + */ + export type packet_set_scoreboard_identity = { + /** ActionType is the type of the action to execute. The action is either ScoreboardIdentityActionRegister to associate an identity with the entry, or ScoreboardIdentityActionClear to remove associations with an entity. */ + action: 'register_identity' | 'clear_identity'; + /** Entries is a list of all entries in the packet. Each of these entries points to one of the entries on a scoreboard. Depending on ActionType, their identity will either be registered or cleared. */ + entries: { + scoreboard_id: bigint; + entity_unique_id?: bigint; + }[]; + }; + /** + * SetLocalPlayerAsInitialised is sent by the client in response to a PlayStatus packet with the status set + * to spawn. The packet marks the moment at which the client is fully initialised and can receive any packet + * without discarding it. + */ + export type packet_set_local_player_as_initialized = { + /** EntityRuntimeID is the entity runtime ID the player was assigned earlier in the login sequence in the StartGame packet. */ + runtime_entity_id: bigint; + }; + /** + * UpdateSoftEnum is sent by the server to update a soft enum, also known as a dynamic enum, previously sent + * in the AvailableCommands packet. It is sent whenever the enum should get new options or when some of its + * options should be removed. + * The UpdateSoftEnum packet will apply for enums that have been set in the AvailableCommands packet with the + * 'Dynamic' field of the CommandEnum set to true. + */ + export type packet_update_soft_enum = { + /** EnumType is the type of the enum. This type must be identical to the one set in the AvailableCommands packet, because the client uses this to recognise which enum to update. */ + enum_type: string; + /** Options is a list of options that should be updated. Depending on the ActionType field, either these options will be added to the enum, the enum options will be set to these options or all of these options will be removed from the enum. */ + options: string[]; + /** ActionType is the type of the action to execute on the enum. The Options field has a different result, depending on what ActionType is used. */ + action_type: 'add' | 'remove' | 'update'; + }; + /** + * NetworkStackLatency is sent by the server (and the client, on development builds) to measure the latency + * over the entire Minecraft stack, rather than the RakNet latency. It has other usages too, such as the + * ability to be used as some kind of acknowledgement packet, to know when the client has received a certain + * other packet. + */ + export type packet_network_stack_latency = { + /** Timestamp is the timestamp of the network stack latency packet. The client will, if NeedsResponse is set to true, send a NetworkStackLatency packet with this same timestamp packet in response. */ + timestamp: bigint; + /** NeedsResponse specifies if the sending side of this packet wants a response to the packet, meaning that the other side should send a NetworkStackLatency packet back. */ + needs_response: number; + }; + /** + * ScriptCustomEvent is sent by both the client and the server. It is a way to let scripts communicate with + * the server, so that the client can let the server know it triggered an event, or the other way around. + * It is essentially an RPC kind of system. + * Deprecated: ScriptCustomEvent is deprecated as of 1.20.10. + */ + export type packet_script_custom_event = { + /** EventName is the name of the event. The script and the server will use this event name to identify the data that is sent. */ + event_name: string; + /** EventData is the data of the event. This data is typically a JSON encoded string, that the script is able to encode and decode too. */ + event_data: string; + }; + /** + * SpawnParticleEffect is sent by the server to spawn a particle effect client-side. Unlike other packets that + * result in the appearing of particles, this packet can show particles that are not hardcoded in the client. + * They can be added and changed through behaviour packs to implement custom particles. + */ + export type packet_spawn_particle_effect = { + /** Dimension is the dimension that the particle is spawned in. Its exact usage is not clear, as the dimension has no direct effect on the particle. */ + dimension: number; + /** EntityUniqueID is the unique ID of the entity that the spawned particle may be attached to. If this ID is not -1, the Position below will be interpreted as relative to the position of the entity associated with this unique ID. */ + entity_id: bigint; + /** Position is the position that the particle should be spawned at. If the position is too far away from the player, it will not show up. If EntityUniqueID is not -1, the position will be relative to the position of the entity. */ + position: vec3f; + /** ParticleName is the name of the particle that should be shown. This name may point to a particle effect that is built-in, or to one implemented by behaviour packs. */ + particle_name: string; + molang_variables?: string; + }; + /** + * AvailableActorIdentifiers is sent by the server at the start of the game to let the client know all + * entities that are available on the server. + */ + export type packet_available_entity_identifiers = { + /** SerialisedEntityIdentifiers is a network NBT serialised compound of all entity identifiers that are available in the server. */ + nbt: any; + }; + /** + * Not used. Use `packet_level_sound_event`. + */ + export type packet_level_sound_event_v2 = { + sound_id: number; + position: vec3f; + block_id: number; + entity_type: string; + is_baby_mob: boolean; + is_global: boolean; + }; + /** + * NetworkChunkPublisherUpdate is sent by the server to change the point around which chunks are and remain + * loaded. This is useful for mini-game servers, where only one area is ever loaded, in which case the + * NetworkChunkPublisherUpdate packet can be sent in the middle of it, so that no chunks ever need to be + * additionally sent during the course of the game. + * In reality, the packet is not extraordinarily useful, and most servers just send it constantly at the + * position of the player. + * If the packet is not sent at all, no chunks will be shown to the player, regardless of where they are sent. + */ + export type packet_network_chunk_publisher_update = { + /** Position is the block position around which chunks loaded will remain shown to the client. Most servers set this position to the position of the player itself. #TODO: Check putSignedBlockPosition */ + coordinates: BlockCoordinates; + /** Radius is the radius in blocks around Position that chunks sent show up in and will remain loaded in. Unlike the RequestChunkRadius and ChunkRadiusUpdated packets, this radius is in blocks rather than chunks, so the chunk radius needs to be multiplied by 16. (Or shifted to the left by 4.) */ + radius: number; + saved_chunks: { + /** ChunkX is the X coordinate of the chunk sent. (To translate a block's X to a chunk's X: x >> 4) */ + x: number; + /** ChunkZ is the Z coordinate of the chunk sent. (To translate a block's Z to a chunk's Z: z >> 4) */ + z: number; + }[]; + }; + /** + * BiomeDefinitionList is sent by the server to let the client know all biomes that are available and + * implemented on the server side. It is much like the AvailableActorIdentifiers packet, but instead + * functions for biomes. + */ + export type packet_biome_definition_list = { + /** BiomeDefinitions is a list of biomes that are available on the server. */ + biome_definitions: BiomeDefinition[]; + /** StringList is a makeshift dictionary implementation Mojang created to try and reduce the size of the overall packet. It is a list of common strings that are used in the biome definitions. */ + string_list: string[]; + }; + /** + * LevelSoundEvent is sent by the server to make any kind of built-in sound heard to a player. It is sent to, + * for example, play a stepping sound or a shear sound. The packet is also sent by the client, in which case + * it could be forwarded by the server to the other players online. If possible, the packets from the client + * should be ignored however, and the server should play them on its own accord. + */ + export type packet_level_sound_event = { + /** SoundType is the type of the sound to play. Some of the sound types require additional data, which is set in the EventData field. */ + sound_id: SoundType; + /** Position is the position of the sound event. The player will be able to hear the direction of the sound based on what position is sent here. */ + position: vec3f; + /** ExtraData is a packed integer that some sound types use to provide extra data. An example of this is the note sound, which is composed of a pitch and an instrument type. */ + extra_data: number; + /** EntityType is the string entity type of the entity that emitted the sound, for example 'minecraft:skeleton'. Some sound types use this entity type for additional data. */ + entity_type: string; + /** BabyMob specifies if the sound should be that of a baby mob. It is most notably used for parrot imitations, which will change based on if this field is set to true or not. */ + is_baby_mob: boolean; + /** DisableRelativeVolume specifies if the sound should be played relatively or not. If set to true, the sound will have full volume, regardless of where the Position is, whereas if set to false, the sound's volume will be based on the distance to Position. */ + is_global: boolean; + /** EntityUniqueID is the unique ID of a source entity. The unique ID is a value that remains consistent across different sessions of the same world, but most servers simply fill the runtime ID of the entity out for this field. */ + entity_unique_id: bigint; + }; + /** + * LevelEventGeneric is sent by the server to send a 'generic' level event to the client. This packet sends an + * NBT serialised object and may for that reason be used for any event holding additional data. + */ + export type packet_level_event_generic = { + /** EventID is a unique identifier that identifies the event called. The data that follows has fields in the NBT depending on what event it is. */ + event_id: number; + /** SerialisedEventData is a network little endian serialised object of event data, with fields that vary depending on EventID. Unlike many other NBT structures, this data is not actually in a compound but just loosely floating NBT tags. To decode using the nbt package, you would need to append 0x0a00 at the start (compound id and name length) and add 0x00 at the end, to manually wrap it in a compound. Likewise, you would have to remove these bytes when encoding. */ + nbt: any; + }; + /** + * LecternUpdate is sent by the client to update the server on which page was opened in a book on a lectern, + * or if the book should be removed from it. + */ + export type packet_lectern_update = { + /** Page is the page number in the book that was opened by the player on the lectern. */ + page: number; + /** PageCount is the number of pages that the book opened in the lectern has. */ + page_count: number; + /** Position is the position of the lectern that was updated. If no lectern is at the block position, the packet should be ignored. */ + position: vec3i; + }; + /** + * This packet was removed. + */ + export type packet_video_stream_connect = { + server_uri: string; + frame_send_frequency: number; + action: 'none' | 'close'; + resolution_x: number; + resolution_y: number; + }; + /** + * ClientCacheStatus is sent by the client to the server at the start of the game. It is sent to let the + * server know if it supports the client-side blob cache. Clients such as Nintendo Switch do not support the + * cache, and attempting to use it anyway will fail. + */ + export type packet_client_cache_status = { + /** Enabled specifies if the blob cache is enabled. If false, the server should not attempt to use the blob cache. If true, it may do so, but it may also choose not to use it. */ + enabled: boolean; + }; + /** + * OnScreenTextureAnimation is sent by the server to show a certain animation on the screen of the player. + * The packet is used, as an example, for when a raid is triggered and when a raid is defeated. + */ + export type packet_on_screen_texture_animation = { + /** AnimationType is the type of the animation to show. The packet provides no further extra data to allow modifying the duration or other properties of the animation. */ + animation_type: number; + }; + /** + * MapCreateLockedCopy is sent by the server to create a locked copy of one map into another map. In vanilla, + * it is used in the cartography table to create a map that is locked and cannot be modified. + */ + export type packet_map_create_locked_copy = { + /** OriginalMapID is the ID of the map that is being copied. The locked copy will obtain all content that is visible on this map, except the content will not change. */ + original_map_id: bigint; + /** NewMapID is the ID of the map that holds the locked copy of the map that OriginalMapID points to. Its contents will be impossible to change. */ + new_map_id: bigint; + }; + /** + * StructureTemplateDataRequest is sent by the client to request data of a structure. + */ + export type packet_structure_template_data_export_request = { + /** StructureName is the name of the structure that was set in the structure block's UI. This is the name used to export the structure to a file. */ + name: string; + /** Position is the position of the structure block that has its template data requested. */ + position: BlockCoordinates; + /** Settings is a struct of settings that should be used for exporting the structure. These settings are identical to the last sent in the StructureBlockUpdate packet by the client. */ + settings: StructureBlockSettings; + /** RequestType specifies the type of template data request that the player sent. */ + request_type: 'export_from_save' | 'export_from_load' | 'query_saved_structure' | 'import_from_save'; + }; + /** + * StructureTemplateDataResponse is sent by the server to send data of a structure to the client in response + * to a StructureTemplateDataRequest packet. + */ + export type packet_structure_template_data_export_response = { + name: string; + success: boolean; + nbt?: any; + /** ResponseType specifies the response type of the packet. This depends on the RequestType field sent in the StructureTemplateDataRequest packet and is one of the constants above. */ + response_type: 'export' | 'query' | 'import'; + }; + /** + * No longer used. + */ + export type packet_update_block_properties = { + nbt: any; + }; + /** + * ClientCacheBlobStatus is part of the blob cache protocol. It is sent by the client to let the server know + * what blobs it needs and which blobs it already has, in an ACK type system. + */ + export type packet_client_cache_blob_status = { + /** The number of MISSes in this packet */ + misses: number; + /** The number of HITs in this packet */ + haves: number; + /** A list of blob hashes that the client does not have a blob available for. The server should send the blobs matching these hashes as soon as possible. */ + missing: bigint[]; + /** A list of hashes that the client does have a cached blob for. Server doesn't need to send. */ + have: bigint[]; + }; + /** + * ClientCacheMissResponse is part of the blob cache protocol. It is sent by the server in response to a + * ClientCacheBlobStatus packet and contains the blob data of all blobs that the client acknowledged not to + * have yet. + */ + export type packet_client_cache_miss_response = { + blobs: Blob[]; + }; + /** + * EducationSettings is a packet sent by the server to update Minecraft: Education Edition related settings. + * It is unused by the normal base game. + */ + export type packet_education_settings = { + /** CodeBuilderDefaultURI is the default URI that the code builder is ran on. Using this, a Code Builder program can make code directly affect the server. */ + CodeBuilderDefaultURI: string; + /** CodeBuilderTitle is the title of the code builder shown when connected to the CodeBuilderDefaultURI. */ + CodeBuilderTitle: string; + /** CanResizeCodeBuilder specifies if clients connected to the world should be able to resize the code builder when it is opened. */ + CanResizeCodeBuilder: boolean; + disable_legacy_title_bar: boolean; + post_process_filter: string; + screenshot_border_path: string; + has_agent_capabilities: boolean; + agent_capabilities?: { + has: boolean; + can_modify_blocks: boolean; + }; + HasOverrideURI: boolean; + OverrideURI?: string; + HasQuiz: boolean; + has_external_link_settings: boolean; + external_link_settings?: { + has: boolean; + url: string; + display_name: string; + }; + }; + /** + * Emote is sent by both the server and the client. When the client sends an emote, it sends this packet to + * the server, after which the server will broadcast the packet to other players online. + */ + export type packet_emote = { + /** EntityRuntimeID is the entity that sent the emote. When a player sends this packet, it has this field set as its own entity runtime ID. */ + entity_id: bigint; + /** EmoteID is the ID of the emote to send. */ + emote_id: string; + /** EmoteLength is the number of ticks that the emote lasts for. */ + emote_length_ticks: number; + /** XUID is the Xbox User ID of the player that sent the emote. It is only set when the emote is used by a player that is authenticated with Xbox Live. */ + xuid: string; + /** PlatformID is an identifier only set for particular platforms when using an emote (presumably only for Nintendo Switch). It is otherwise an empty string, and is used to decide which players are able to emote with each other. */ + platform_id: string; + /** Flags is a combination of flags that change the way the Emote packet operates. When the server sends this packet to other players, EmoteFlagServerSide must be present. */ + flags: 'server_side' | 'mute_chat'; + }; + /** + * MultiPlayerSettings is sent by the client to update multi-player related settings server-side and sent back + * to online players by the server. + * The MultiPlayerSettings packet is a Minecraft: Education Edition packet. It has no functionality for the + * base game. + */ + export type packet_multiplayer_settings = { + /** ActionType is the action that should be done when this packet is sent. It is one of the constants that may be found above. */ + action_type: 'enable_multiplayer' | 'disable_multiplayer' | 'refresh_join_code'; + }; + /** + * SettingsCommand is sent by the client when it changes a setting in the settings that results in the issuing + * of a command to the server, such as when Show Coordinates is enabled. + */ + export type packet_settings_command = { + /** CommandLine is the full command line that was sent to the server as a result of the setting that the client changed. */ + command_line: string; + /** SuppressOutput specifies if the client requests the suppressing of the output of the command that was executed. Generally this is set to true, as the client won't need a message to confirm the output of the change. */ + suppress_output: boolean; + }; + /** + * AnvilDamage is sent by the client to request the dealing damage to an anvil. This packet is completely + * pointless and the server should never listen to it. + */ + export type packet_anvil_damage = { + /** Damage is the damage that the client requests to be dealt to the anvil. */ + damage: number; + /** AnvilPosition is the position in the world that the anvil can be found at. */ + position: BlockCoordinates; + }; + /** + * CompletedUsingItem is sent by the server to tell the client that it should be done using the item it is + * currently using. + */ + export type packet_completed_using_item = { + /** UsedItemID is the item ID of the item that the client completed using. This should typically be the ID of the item held in the hand. */ + used_item_id: number; + /** UseMethod is the method of the using of the item that was completed. It is one of the constants that may be found above. */ + use_method: + | 'equip_armor' + | 'eat' + | 'attack' + | 'consume' + | 'throw' + | 'shoot' + | 'place' + | 'fill_bottle' + | 'fill_bucket' + | 'pour_bucket' + | 'use_tool' + | 'interact' + | 'retrieved' + | 'dyed' + | 'traded' + | 'brushing_completed' + | 'opened_vault'; + }; + /** + * NetworkSettings is sent by the server to update a variety of network settings. These settings modify the + * way packets are sent over the network stack. + */ + export type packet_network_settings = { + /** CompressionThreshold is the minimum size of a packet that is compressed when sent. If the size of a packet is under this value, it is not compressed. When set to 0, all packets will be left uncompressed. */ + compression_threshold: number; + /** CompressionAlgorithm is the algorithm that is used to compress packets. */ + compression_algorithm: 'deflate' | 'snappy'; + /** ClientThrottle regulates whether the client should throttle players when exceeding of the threshold. Players outside threshold will not be ticked, improving performance on low-end devices. */ + client_throttle: boolean; + /** ClientThrottleThreshold is the threshold for client throttling. If the number of players exceeds this value, the client will throttle players. */ + client_throttle_threshold: number; + /** ClientThrottleScalar is the scalar for client throttling. The scalar is the amount of players that are ticked when throttling is enabled. */ + client_throttle_scalar: number; + }; + /** + * PlayerAuthInput is sent by the client to allow for server authoritative movement. It is used to synchronise + * the player input with the position server-side. + * The client sends this packet when the ServerAuthoritativeMovementMode field in the StartGame packet is set + * to true, instead of the MovePlayer packet. The client will send this packet once every tick. + */ + export type packet_player_auth_input = { + /** Pitch that the player reports it has. */ + pitch: number; + /** Yaw that player reports it has. */ + yaw: number; + /** Position holds the position that the player reports it has. */ + position: vec3f; + /** MoveVector is a Vec2 that specifies the direction in which the player moved, as a combination of X/Z values which are created using the WASD/controller stick state. */ + move_vector: vec2f; + /** HeadYaw is the horizontal rotation of the head that the player reports it has. */ + head_yaw: number; + /** InputData is a combination of bit flags that together specify the way the player moved last tick. It is a combination of the flags above. */ + input_data: InputFlag; + /** InputMode specifies the way that the client inputs data to the screen. It is one of the constants that may be found above. */ + input_mode: 'unknown' | 'mouse' | 'touch' | 'game_pad' | 'motion_controller'; + /** PlayMode specifies the way that the player is playing. The values it holds, which are rather random, may be found above. */ + play_mode: 'normal' | 'teaser' | 'screen' | 'viewer' | 'reality' | 'placement' | 'living_room' | 'exit_level' | 'exit_level_living_room' | 'num_modes'; + /** InteractionModel is a constant representing the interaction model the player is using. */ + interaction_model: 'touch' | 'crosshair' | 'classic'; + /** interact_rotation is the rotation the player is looking that they intend to use for interactions. This is only different to Pitch and Yaw in cases such as VR or when custom cameras being used. */ + interact_rotation: vec2f; + /** Tick is the server tick at which the packet was sent. It is used in relation to CorrectPlayerMovePrediction. */ + tick: bigint; + /** Delta was the delta between the old and the new position. There isn't any practical use for this field as it can be calculated by the server itself. */ + delta: vec3f; + transaction?: { + legacy: TransactionLegacy; + actions: TransactionActions; + data: TransactionUseItem; + }; + item_stack_request?: ItemStackRequest; + vehicle_rotation?: vec2f; + predicted_vehicle?: bigint; + block_action?: { + action: Action; + position?: vec3i; + face?: number; + }[]; + analogue_move_vector: vec2f; + camera_orientation: vec3f; + raw_move_vector: vec2f; + }; + export type InputFlag = { + ascend?: boolean; + descend?: boolean; + north_jump?: boolean; + jump_down?: boolean; + sprint_down?: boolean; + change_height?: boolean; + jumping?: boolean; + auto_jumping_in_water?: boolean; + sneaking?: boolean; + sneak_down?: boolean; + up?: boolean; + down?: boolean; + left?: boolean; + right?: boolean; + up_left?: boolean; + up_right?: boolean; + want_up?: boolean; + want_down?: boolean; + want_down_slow?: boolean; + want_up_slow?: boolean; + sprinting?: boolean; + ascend_block?: boolean; + descend_block?: boolean; + sneak_toggle_down?: boolean; + persist_sneak?: boolean; + start_sprinting?: boolean; + stop_sprinting?: boolean; + start_sneaking?: boolean; + stop_sneaking?: boolean; + start_swimming?: boolean; + stop_swimming?: boolean; + start_jumping?: boolean; + start_gliding?: boolean; + stop_gliding?: boolean; + item_interact?: boolean; + block_action?: boolean; + item_stack_request?: boolean; + handled_teleport?: boolean; + emoting?: boolean; + missed_swing?: boolean; + start_crawling?: boolean; + stop_crawling?: boolean; + start_flying?: boolean; + stop_flying?: boolean; + received_server_data?: boolean; + client_predicted_vehicle?: boolean; + paddling_left?: boolean; + paddling_right?: boolean; + block_breaking_delay_enabled?: boolean; + horizontal_collision?: boolean; + vertical_collision?: boolean; + down_left?: boolean; + down_right?: boolean; + start_using_item?: boolean; + camera_relative_movement_enabled?: boolean; + rot_controlled_by_move_direction?: boolean; + start_spin_attack?: boolean; + stop_spin_attack?: boolean; + hotbar_only_touch?: boolean; + jump_released_raw?: boolean; + jump_pressed_raw?: boolean; + jump_current_raw?: boolean; + sneak_released_raw?: boolean; + sneak_pressed_raw?: boolean; + sneak_current_raw?: boolean; + }; + /** + * CreativeContent is a packet sent by the server to set the creative inventory's content for a player. + * Introduced in 1.16, this packet replaces the previous method - sending an InventoryContent packet with + * creative inventory window ID. + * As of v1.16.100, this packet must be sent during the login sequence. Not sending it will stop the client + * from joining the server. + */ + export type packet_creative_content = { + /** The groups that are displayed within the creative inventory menu. */ + groups: { + /** Where the group shows up in the creative inventory (e.g. Construction, Items, etc) */ + category: 'all' | 'construction' | 'nature' | 'equipment' | 'items' | 'item_command_only'; + /** The name of the group (e.g. Decorative stone, Wool, etc.) */ + name: string; + /** The item whose icon is used to label the group (icon you click on to open/close the group) */ + icon_item: ItemLegacy; + }[]; + /** Individual items that are displayed within the creative inventory menu, grouped by their category. */ + items: { + /** The index of the item in the creative menu. */ + entry_id: number; + item: ItemLegacy; + /** The group index of the item - which group in the groups array this item belongs to. */ + group_index: number; + }[]; + }; + /** + * PlayerEnchantOptions is sent by the server to update the enchantment options displayed when the user opens + * the enchantment table and puts an item in. This packet was added in 1.16 and allows the server to decide on + * the enchantments that can be selected by the player. + * The PlayerEnchantOptions packet should be sent once for every slot update of the enchantment table. The + * vanilla server sends an empty PlayerEnchantOptions packet when the player opens the enchantment table + * (air is present in the enchantment table slot) and sends the packet with actual enchantments in it when + * items are put in that can have enchantments. + */ + export type packet_player_enchant_options = { + /** Options is a list of possible enchantment options for the item that was put into the enchantment table. */ + options: EnchantOption[]; + }; + /** + * ItemStackRequest is sent by the client to change item stacks in an inventory. It is essentially a + * replacement of the InventoryTransaction packet added in 1.16 for inventory specific actions, such as moving + * items around or crafting. The InventoryTransaction packet is still used for actions such as placing blocks + * and interacting with entities. + */ + export type packet_item_stack_request = { + requests: ItemStackRequest[]; + }; + /** + * ItemStackResponse is sent by the server in response to an ItemStackRequest packet from the client. This + * packet is used to either approve or reject ItemStackRequests from the client. If a request is approved, the + * client will simply continue as normal. If rejected, the client will undo the actions so that the inventory + * should be in sync with the server again. + */ + export type packet_item_stack_response = { + /** Responses is a list of responses to ItemStackRequests sent by the client before. Responses either approve or reject a request from the client. Vanilla limits the size of this slice to 4096. */ + responses: ItemStackResponses; + }; + /** + * PlayerArmourDamage is sent by the server to damage the armour of a player. It is a very efficient packet, + * but generally it's much easier to just send a slot update for the damaged armour. + */ + export type packet_player_armor_damage = { + entries: ArmorDamageEntry[]; + }; + export type ArmorDamageEntry = { + armor_slot: 'helmet' | 'chestplate' | 'leggings' | 'boots' | 'body'; + damage: number; + }; + /** + * CodeBuilder is an Education Edition packet sent by the server to the client to open the URL to a Code + * Builder (websocket) server. + */ + export type packet_code_builder = { + /** URL is the url to the Code Builder (websocket) server. */ + url: string; + /** ShouldOpenCodeBuilder specifies if the client should automatically open the Code Builder app. If set to true, the client will attempt to use the Code Builder app to connect to and interface with the server running at the URL above. */ + should_open_code_builder: boolean; + }; + /** + * UpdatePlayerGameType is sent by the server to change the game mode of a player. It is functionally + * identical to the SetPlayerGameType packet. + */ + export type packet_update_player_game_type = { + /** GameType is the new game type of the player. It is one of the constants that can be found in set_player_game_type.go. Some of these game types require additional flags to be set in an AdventureSettings packet for the game mode to obtain its full functionality. */ + gamemode: GameMode; + /** PlayerUniqueID is the entity unique ID of the player that should have its game mode updated. If this packet is sent to other clients with the player unique ID of another player, nothing happens. */ + player_unique_id: bigint; + tick: bigint; + }; + /** + * EmoteList is sent by the client every time it joins the server and when it equips new emotes. It may be + * used by the server to find out which emotes the client has available. If the player has no emotes equipped, + * this packet is not sent. + * Under certain circumstances, this packet is also sent from the server to the client, but I was unable to + * find when this is done. + */ + export type packet_emote_list = { + /** PlayerRuntimeID is the runtime ID of the player that owns the emote pieces below. If sent by the client, this player runtime ID is always that of the player itself. */ + player_id: bigint; + /** EmotePieces is a list of emote pieces that the player with the runtime ID above has. */ + emote_pieces: string[]; + }; + /** + * PositionTrackingDBClientRequest is a packet sent by the client to request the position and dimension of a + * 'tracking ID'. These IDs are tracked in a database by the server. In 1.16, this is used for lodestones. + * The client will send this request to find the position a lodestone compass needs to point to. If found, it + * will point to the lodestone. If not, it will start spinning around. + * A PositionTrackingDBServerBroadcast packet should be sent in response to this packet. + */ + export type packet_position_tracking_db_request = { + /** RequestAction is the action that should be performed upon the receiving of the packet. It is one of the constants found above. */ + action: 'query'; + /** TrackingID is a unique ID used to identify the request. The server responds with a PositionTrackingDBServerBroadcast packet holding the same ID, so that the client can find out what that packet was in response to. */ + tracking_id: number; + }; + /** + * PositionTrackingDBServerBroadcast is sent by the server in response to the + * PositionTrackingDBClientRequest packet. This packet is, as of 1.16, currently only used for lodestones. The + * server maintains a database with tracking IDs and their position and dimension. The client will request + * these tracking IDs, (NBT tag set on the lodestone compass with the tracking ID?) and the server will + * respond with the status of those tracking IDs. + * What is actually done with the data sent depends on what the client chooses to do with it. For the + * lodestone compass, it is used to make the compass point towards lodestones and to make it spin if the + * lodestone at a position is no longer there. + */ + export type packet_position_tracking_db_broadcast = { + /** BroadcastAction specifies the status of the position tracking DB response. It is one of the constants above, specifying the result of the request with the ID below. The Update action is sent for setting the position of a lodestone compass, the Destroy and NotFound to indicate that there is not (no longer) a lodestone at that position. */ + broadcast_action: 'update' | 'destory' | 'not_found'; + /** TrackingID is the ID of the PositionTrackingDBClientRequest packet that this packet was in response to. The tracking ID is also present as the 'id' field in the SerialisedData field. */ + tracking_id: number; + nbt: any; + }; + /** + * DebugInfo is a packet sent by the server to the client. It does not seem to do anything when sent to the + * normal client in 1.16. + */ + export type packet_debug_info = { + /** PlayerUniqueID is the unique ID of the player that the packet is sent to. */ + player_unique_id: bigint; + /** Data is the debug data. */ + data: ByteArray; + }; + /** + * PacketViolationWarning is sent by the client when it receives an invalid packet from the server. It holds + * some information on the error that occurred. + */ + export type packet_packet_violation_warning = { + violation_type: 'malformed'; + /** Severity specifies the severity of the packet violation. The action the client takes after this violation depends on the severity sent. */ + severity: 'warning' | 'final_warning' | 'terminating'; + /** PacketID is the ID of the invalid packet that was received. */ + packet_id: number; + /** ViolationContext holds a description on the violation of the packet. */ + reason: string; + }; + /** + * MotionPredictionHints is sent by the server to the client. There is a predictive movement component for + * entities. This packet fills the "history" of that component and entity movement is computed based on the + * points. Vanilla sends this packet instead of the SetActorMotion packet when 'spatial optimisations' are + * enabled. + */ + export type packet_motion_prediction_hints = { + /** EntityRuntimeID is the runtime ID of the entity whose velocity is sent to the client. */ + entity_runtime_id: bigint; + /** Velocity is the server-calculated velocity of the entity at the point of sending the packet. */ + velocity: vec3f; + /** OnGround specifies if the server currently thinks the entity is on the ground. */ + on_ground: boolean; + }; + /** + * AnimateEntity is sent by the server to animate an entity client-side. It may be used to play a single + * animation, or to activate a controller which can start a sequence of animations based on different + * conditions specified in an animation controller. + * Much of the documentation of this packet can be found at + * https://learn.microsoft.com/minecraft/creator/reference/content/animationsreference. + */ + export type packet_animate_entity = { + /** Animation is the name of a single animation to start playing. */ + animation: string; + /** NextState is the first state to start with. These states are declared in animation controllers (which, in themselves, are animations too). These states in turn may have animations and transitions to move to a next state. */ + next_state: string; + /** StopCondition is a MoLang expression that specifies when the animation should be stopped. */ + stop_condition: string; + /** StopConditionVersion is the MoLang stop condition version. */ + stop_condition_version: number; + /** Controller is the animation controller that is used to manage animations. These controllers decide when to play which animation. */ + controller: string; + /** How long to move from the previous animation to the next. */ + blend_out_time: number; + /** EntityRuntimeIDs is list of runtime IDs of entities that the animation should be applied to. */ + runtime_entity_ids: bigint[]; + }; + /** + * CameraShake is sent by the server to make the camera shake client-side. This feature was added for map- + * making partners. + */ + export type packet_camera_shake = { + /** Intensity is the intensity of the shaking. The client limits this value to 4, so anything higher may not work. */ + intensity: number; + /** Duration is the number of seconds the camera will shake for. */ + duration: number; + /** Type is the type of shake, and is one of the constants listed above. The different type affects how the shake looks in game. */ + type: number; + /** Action is the action to be performed, and is one of the constants listed above. Currently the different actions will either add or stop shaking the client. */ + action: 'add' | 'stop'; + }; + /** + * PlayerFog is sent by the server to render the different fogs in the Stack. The types of fog are controlled + * by resource packs to change how they are rendered, and the ability to create custom fog. + */ + export type packet_player_fog = { + /** Stack is a list of fog identifiers to be sent to the client. Examples of fog identifiers are "minecraft:fog_ocean" and "minecraft:fog_hell". */ + stack: string[]; + }; + /** + * CorrectPlayerMovePrediction is sent by the server if and only if StartGame.ServerAuthoritativeMovementMode + * is set to AuthoritativeMovementModeServerWithRewind. The packet is used to correct movement at a specific + * point in time. + */ + export type packet_correct_player_move_prediction = { + prediction_type: 'player' | 'vehicle'; + /** Position is the position that the player is supposed to be at at the tick written in the field below. The client will change its current position based on movement after that tick starting from the Position. */ + position: vec3f; + /** Delta is the change in position compared to what the client sent as its position at that specific tick. */ + delta: vec3f; + rotation: vec2f; + angular_velocity?: number; + /** OnGround specifies if the player was on the ground at the time of the tick below. */ + on_ground: boolean; + /** Tick is the tick of the movement which was corrected by this packet. */ + tick: bigint; + }; + /** + * ItemRegistryPacket is used to declare what items the server makes available, and components of custom items. + * After 1.21.60, this packet replaces the functionality of the "itemstates" field in the StartGamePacket + * In pre-1.21.60 versions, this was the ItemComponentPacket, and was + * sent by the server to attach client-side components to custom items. + */ + export type packet_item_registry = { + /** `items` holds a list of all items. */ + itemstates: Itemstates; + }; + /** + * FilterText is sent by the both the client and the server. The client sends the packet to the server to + * allow the server to filter the text server-side. The server then responds with the same packet and the + * safer version of the text. + */ + export type packet_filter_text_packet = { + /** Text is either the text from the client or the safer version of the text sent by the server. */ + text: string; + /** FromServer indicates if the packet was sent by the server or not. */ + from_server: boolean; + }; + /** + * ClientBoundDebugRenderer is sent by the server to spawn an outlined cube on client-side. + */ + export type packet_debug_renderer = { + type: string; + has_data: boolean; + data?: DebugMarkerData; + }; + /** + * Sent by the server to synchronize/update entity properties as NBT, an alternative to Set Entity Data. + */ + export type packet_sync_entity_property = { + nbt: any; + }; + /** + * AddVolumeEntity sends a volume entity's definition and components from server to client. + */ + export type packet_add_volume_entity = { + /** EntityRuntimeID is the runtime ID of the entity. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + runtime_id: bigint; + /** EntityMetadata is a map of entity metadata, which includes flags and data properties that alter in particular the way the entity looks. */ + nbt: any; + encoding_identifier: string; + instance_name: string; + bounds: { + min: BlockCoordinates; + max: BlockCoordinates; + }; + dimension: number; + engine_version: string; + }; + /** + * RemoveVolumeEntity indicates a volume entity to be removed from server to client. + */ + export type packet_remove_volume_entity = { + /** The Runtime Entity ID */ + entity_id: bigint; + }; + /** + * SimulationType is an in-progress packet. We currently do not know the use case. + */ + export type packet_simulation_type = { + /** SimulationType is the simulation type selected */ + type: 'game' | 'editor' | 'test' | 'invalid'; + }; + /** + * NPCDialogue is a packet that allows the client to display dialog boxes for interacting with NPCs. + */ + export type packet_npc_dialogue = { + /** ActorUniqueID is the ID of the NPC being requested. */ + entity_id: bigint; + /** ActionType is the type of action for the packet. */ + action_type: 'open' | 'close'; + /** Dialogue is the text that the client should see. */ + dialogue: string; + /** SceneName is the scene the data was pulled from for the client. */ + screen_name: string; + /** NPCName is the name of the NPC to be displayed to the client. */ + npc_name: string; + /** ActionJSON is the JSON string of the buttons/actions the server can perform. */ + action_json: string; + }; + export type packet_edu_uri_resource_packet = { + resource: EducationSharedResourceURI; + }; + export type packet_create_photo = { + entity_unique_id: bigint; + photo_name: string; + item_name: string; + }; + export type packet_update_subchunk_blocks = { + x: number; + y: number; + z: number; + blocks: BlockUpdate[]; + extra: BlockUpdate[]; + }; + export type packet_photo_info_request = { + photo_id: bigint; + }; + export type HeightMapDataType = 'no_data' | 'has_data' | 'too_high' | 'too_low' | 'all_copied'; + export type SubChunkEntryWithoutCaching = { + dx: number; + dy: number; + dz: number; + result: 'undefined' | 'success' | 'chunk_not_found' | 'invalid_dimension' | 'player_not_found' | 'y_index_out_of_bounds' | 'success_all_air'; + payload: ByteArray; + heightmap_type: HeightMapDataType; + heightmap?: Buffer; + render_heightmap_type: HeightMapDataType; + render_heightmap?: Buffer; + }[]; + export type SubChunkEntryWithCaching = { + dx: number; + dy: number; + dz: number; + result: 'undefined' | 'success' | 'chunk_not_found' | 'invalid_dimension' | 'player_not_found' | 'y_index_out_of_bounds' | 'success_all_air'; + /** Payload has the terrain data, if the chunk isn't empty and caching is disabled */ + payload?: ByteArray; + heightmap_type: HeightMapDataType; + heightmap?: Buffer; + render_heightmap_type: HeightMapDataType; + render_heightmap?: Buffer; + blob_id: bigint; + }[]; + /** + * SubChunk sends data about multiple sub-chunks around a center point. + */ + export type packet_subchunk = { + cache_enabled: boolean; + dimension: number; + /** Origin point */ + origin: vec3i; + blob_id: any; + entries?: SubChunkEntryWithCaching | SubChunkEntryWithoutCaching; + }; + export type packet_subchunk_request = { + dimension: number; + /** Origin point */ + origin: vec3i; + requests: { + dx: number; + dy: number; + dz: number; + }[]; + }; + /** + * ClientStartItemCooldown is sent by the client to the server to initiate a cooldown on an item. The purpose of this + * packet isn't entirely clear. + */ + export type packet_client_start_item_cooldown = { + category: string; + /** Duration is the duration of ticks the cooldown should last. */ + duration: number; + }; + /** + * ScriptMessage is used to communicate custom messages from the client to the server, or from the server to the client. + * While the name may suggest this packet is used for the discontinued scripting API, it is likely instead for the + * GameTest framework. + */ + export type packet_script_message = { + /** Message ID is the identifier of the message, used by either party to identify the message data sent. */ + message_id: string; + /** Data contains the data of the message. */ + data: string; + }; + /** + * CodeBuilderSource is an Education Edition packet sent by the client to the server to run an operation with a + */ + export type packet_code_builder_source = { + /** Operation is used to distinguish the operation performed. It is always one of the constants listed above. */ + operation: 'none' | 'get' | 'set' | 'reset'; + /** Category is used to distinguish the category of the operation performed. It is always one of the constants */ + category: 'none' | 'code_status' | 'instantiation'; + code_status: 'none' | 'not_started' | 'in_progress' | 'paused' | 'error' | 'succeeded'; + }; + /** + * TickingAreasLoadStatus is sent by the server to the client to notify the client of a ticking area's loading status. + */ + export type packet_ticking_areas_load_status = { + /** Preload is true if the server is waiting for the area's preload. */ + preload: boolean; + }; + /** + * DimensionData is a packet sent from the server to the client containing information about data-driven dimensions + * that the server may have registered. This packet does not seem to be sent by default, rather only being sent when + * any data-driven dimensions are registered. + */ + export type packet_dimension_data = { + definitions: { + id: string; + max_height: number; + min_height: number; + generator: 'legacy' | 'overworld' | 'flat' | 'nether' | 'end' | 'void'; + }[]; + }; + /** + * AgentAction is an Education Edition packet sent from the server to the client to return a response to a + * previously requested action. + */ + export type packet_agent_action = { + request_id: string; + action_type: + | 'none' + | 'attack' + | 'collect' + | 'destroy' + | 'detect_redstone' + | 'detect_obstacle' + | 'drop' + | 'drop_all' + | 'inspect' + | 'inspect_data' + | 'inspect_item_count' + | 'inspect_item_detail' + | 'inspect_item_space' + | 'interact' + | 'move' + | 'place_block' + | 'till' + | 'transfer_item_to' + | 'turn'; + body: string; + }; + /** + * ChangeMobProperty is a packet sent from the server to the client to change one of the properties of a mob client-side. + */ + export type packet_change_mob_property = { + /** EntityUniqueID is the unique ID of the entity whose property is being changed. */ + entity_unique_id: bigint; + /** Property is the name of the property being updated. */ + property: string; + /** BoolValue is set if the property value is a bool type. If the type is not a bool, this field is ignored. */ + bool_value: boolean; + /** StringValue is set if the property value is a string type. If the type is not a string, this field is ignored. */ + string_value: string; + /** IntValue is set if the property value is an int type. If the type is not an int, this field is ignored. */ + int_value: number; + /** FloatValue is set if the property value is a float type. If the type is not a float, this field is ignored. */ + float_value: number; + }; + /** + * LessonProgress is a packet sent by the server to the client to inform the client of updated progress on a lesson. + * This packet only functions on the Minecraft: Education Edition version of the game. + */ + export type packet_lesson_progress = { + /** Action is the action the client should perform to show progress. This is one of the constants defined above. */ + action: number; + /** Score is the score the client should use when displaying the progress. */ + score: number; + /** Identifier is the identifier of the lesson that is being progressed. */ + identifier: string; + }; + /** + * RequestAbility is a packet sent by the client to the server to request permission for a specific ability from the + * server. + */ + export type packet_request_ability = { + /** Ability is the ability that the client is requesting. This is one of the constants defined above. */ + ability: + | 'build' + | 'mine' + | 'doors_and_switches' + | 'open_containers' + | 'attack_players' + | 'attack_mobs' + | 'operator_commands' + | 'teleport' + | 'invulnerable' + | 'flying' + | 'may_fly' + | 'instant_build' + | 'lightning' + | 'fly_speed' + | 'walk_speed' + | 'muted' + | 'world_builder' + | 'no_clip' + | 'ability_count'; + /** Value type decides which of the fields you should read/write from */ + value_type: 'bool' | 'float'; + /** If value type is bool, use this value */ + bool_value: boolean; + /** If value type is float, use this value */ + float_val: number; + }; + /** + * RequestPermissions is a packet sent from the client to the server to request permissions that the client does not + * currently have. It can only be sent by operators and host in vanilla Minecraft. + */ + export type packet_request_permissions = { + /** EntityUniqueID is the unique ID of the player. The unique ID is unique for the entire world and is often used in packets. Most servers send an EntityUniqueID equal to the EntityRuntimeID. */ + entity_unique_id: bigint; + /** PermissionLevel is the current permission level of the player. Same as constants in AdventureSettings packet. */ + permission_level: PermissionLevel; + /** RequestedPermissions contains the requested permission flags. */ + requested_permissions: RequestPermissions; + }; + export type RequestPermissions = { + build?: boolean; + mine?: boolean; + doors_and_switches?: boolean; + open_containers?: boolean; + attack_players?: boolean; + attack_mobs?: boolean; + operator?: boolean; + teleport?: boolean; + }; + /** + * ToastRequest is a packet sent from the server to the client to display a toast to the top of the screen. These toasts + * are the same as the ones seen when, for example, loading a new resource pack or obtaining an achievement. + */ + export type packet_toast_request = { + /** Title is the title of the toast. */ + title: string; + /** Message is the message that the toast may contain alongside the title. */ + message: string; + }; + /** + * UpdateAbilities is a packet sent from the server to the client to update the abilities of the player. It, along with + * the UpdateAdventureSettings packet, are replacements of the AdventureSettings packet since v1.19.10. + */ + export type packet_update_abilities = { + /** EntityUniqueID is the unique ID of the player. The unique ID is a value that remains consistent across different sessions of the same world, but most servers simply fill the runtime ID of the entity out for this field. */ + entity_unique_id: bigint; + /** PlayerPermissions is the permission level of the player. It is a value from 0-3, with 0 being visitor, 1 being member, 2 being operator and 3 being custom. */ + permission_level: PermissionLevel; + /** CommandPermissions is a permission level that specifies the kind of commands that the player is allowed to use. It is one of the CommandPermissionLevel constants in the AdventureSettings packet. */ + command_permission: CommandPermissionLevel; + /** Layers contains all ability layers and their potential values. This should at least have one entry, being the base layer. */ + abilities: AbilityLayers[]; + }; + /** + * UpdateAdventureSettings is a packet sent from the server to the client to update the adventure settings of the player. + * It, along with the UpdateAbilities packet, are replacements of the AdventureSettings packet since v1.19.10. + */ + export type packet_update_adventure_settings = { + /** NoPvM is a boolean indicating whether the player is allowed to fight mobs or not. */ + no_pvm: boolean; + /** NoMvP is a boolean indicating whether mobs are allowed to fight the player or not. It is unclear why this is sent to the client. */ + no_mvp: boolean; + /** ImmutableWorld is a boolean indicating whether the player is allowed to modify the world or not. */ + immutable_world: boolean; + /** ShowNameTags is a boolean indicating whether player name tags are shown or not. */ + show_name_tags: boolean; + /** AutoJump is a boolean indicating whether the player is allowed to jump automatically or not. */ + auto_jump: boolean; + }; + /** + * DeathInfo is a packet sent from the server to the client expected to be sent when a player dies. It contains messages + * related to the player's death, which are shown on the death screen as of v1.19.10. + */ + export type packet_death_info = { + /** Cause is the cause of the player's death, such as "suffocation" or "suicide". */ + cause: string; + /** Messages is a list of death messages to be shown on the death screen. */ + messages: string[]; + }; + /** + * EditorNetwork is a packet sent from the server to the client and vise-versa to communicate editor-mode related + * information. It carries a single compound tag containing the relevant information. + */ + export type packet_editor_network = { + route_to_manager: boolean; + /** Payload is a network little endian compound tag holding data relevant to the editor. */ + payload: any; + }; + /** + * FeatureRegistry is a packet used to notify the client about the world generation features the server is currently + * using. This is used in combination with the client-side world generation system introduced in v1.19.20, allowing the + * client to completely generate the chunks of the world without having to rely on the server. + */ + export type packet_feature_registry = { + /** Features is a slice of all registered world generation features. */ + features: { + name: string; + options: string; + }[]; + }; + /** + * ServerStats is a packet sent from the server to the client to update the client on server statistics. It is purely + * used for telemetry. + */ + export type packet_server_stats = { + server_time: number; + network_time: number; + }; + export type packet_request_network_settings = { + client_protocol: number; + }; + export type packet_game_test_request = { + /** MaxTestsPerBatch ... */ + max_tests_per_batch: number; + /** Repetitions represents the amount of times the test will be run. */ + repetitions: number; + /** Rotation represents the rotation of the test. It is one of the constants above. */ + rotation: '0deg' | '90deg' | '180deg' | '270deg' | '360deg'; + stop_on_error: boolean; + position: BlockCoordinates; + tests_per_row: number; + name: string; + }; + export type packet_game_test_results = { + succeeded: boolean; + error: string; + name: string; + }; + export type InputLockFlags = { + move?: boolean; + jump?: boolean; + sneak?: boolean; + mount?: boolean; + dismount?: boolean; + rotation?: boolean; + }; + export type packet_update_client_input_locks = { + locks: InputLockFlags; + position: vec3f; + }; + export type packet_client_cheat_ability = { + entity_unique_id: bigint; + permission_level: PermissionLevel; + command_permission: CommandPermissionLevel; + abilities: AbilityLayers[]; + }; + export type packet_camera_presets = { + presets: CameraPresets[]; + }; + export type packet_unlocked_recipes = { + unlock_type: 'empty' | 'initially_unlocked' | 'newly_unlocked' | 'remove_unlocked' | 'remove_all_unlocked'; + recipes: string[]; + }; + export type packet_camera_instruction = { + instruction_set?: { + runtime_id: number; + ease_data?: { + type: EaseType; + duration: number; + }; + position?: vec3f; + rotation?: vec2f; + facing?: vec3f; + offset?: vec2f; + entity_offset?: vec3f; + default?: boolean; + remove_ignore_starting_values: boolean; + }; + clear?: boolean; + fade?: { + fade_in_duration: number; + wait_duration: number; + fade_out_duration: number; + color_rgb: vec3f; + }; + target?: { + offset?: vec3f; + entity_unique_id: bigint; + }; + remove_target?: boolean; + fov?: { + field_of_view: number; + ease_time: number; + ease_type: EaseType; + clear: boolean; + }; + spline?: CameraSplineInstruction; + attach_to_entity?: bigint; + detach_from_entity?: boolean; + }; + export type packet_compressed_biome_definitions = { + raw_payload: ByteArray; + }; + export type packet_trim_data = { + patterns: { + item_name: string; + pattern: string; + }[]; + materials: { + material: string; + color: string; + item_name: string; + }[]; + }; + export type packet_open_sign = { + position: BlockCoordinates; + is_front: boolean; + }; + /** + * agent_animation is an Education Edition packet sent from the server to the client to make an agent perform an animation. + */ + export type packet_agent_animation = { + /** animation is the ID of the animation that the agent should perform. As of its implementation, there are no IDs that can be used in the regular client. */ + animation: 'arm_swing' | 'shrug'; + /** entity_runtime_id is the runtime ID of the entity. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + entity_runtime_id: bigint; + }; + /** + * RefreshEntitlements is sent by the client to the server to refresh the entitlements of the player. + */ + export type packet_refresh_entitlements = {}; + export type packet_toggle_crafter_slot_request = { + position: vec3li; + slot: number; + disabled: boolean; + }; + export type packet_set_player_inventory_options = { + left_tab: 'none' | 'construction' | 'equipment' | 'items' | 'nature' | 'search' | 'survival'; + right_tab: 'none' | 'fullscreen' | 'crafting' | 'armor'; + filtering: boolean; + layout: 'none' | 'survival' | 'recipe_book' | 'creative'; + crafting_layout: 'none' | 'survival' | 'recipe_book' | 'creative'; + }; + export type packet_set_hud = { + elements: Element[]; + visibility: 'hide' | 'reset'; + }; + export type Element = + | 'PaperDoll' + | 'Armour' + | 'ToolTips' + | 'TouchControls' + | 'Crosshair' + | 'HotBar' + | 'Health' + | 'ProgressBar' + | 'Hunger' + | 'AirBubbles' + | 'VehicleHealth' + | 'EffectsBar' + | 'ItemTextPopup'; + export type packet_award_achievement = { + achievement_id: number; + }; + export type packet_server_post_move = { + position: vec3f; + }; + /** + * clientbound_close_form is sent by the server to clear the entire form stack of the client. This means that all + * forms that are currently open will be closed. This does not affect inventories and other containers. + */ + export type packet_clientbound_close_form = {}; + export type packet_serverbound_loading_screen = { + type: number; + loading_screen_id?: number; + }; + export type packet_jigsaw_structure_data = { + structure_data: any; + }; + export type packet_current_structure_feature = { + current_feature: string; + }; + export type packet_serverbound_diagnostics = { + average_frames_per_second: number; + average_server_sim_tick_time: number; + average_client_sim_tick_time: number; + average_begin_frame_time: number; + average_input_time: number; + average_render_time: number; + average_end_frame_time: number; + average_remainder_time_percent: number; + average_unaccounted_time_percent: number; + }; + export type packet_camera_aim_assist = { + preset_id: string; + view_angle: vec2f; + distance: number; + target_mode: 'angle' | 'distance'; + action: 'set' | 'clear'; + show_debug_render: boolean; + }; + export type packet_container_registry_cleanup = { + removed_containers: FullContainerName[]; + }; + export type packet_movement_effect = { + runtime_id: bigint; + effect_type: MovementEffectType; + effect_duration: number; + tick: bigint; + }; + export type packet_set_movement_authority = { + movement_authority: 'client' | 'server' | 'server_with_rewind'; + }; + /** + * CameraAimAssistPresets is sent by the server to the client to provide a list of categories and presets + * that can be used when sending a CameraAimAssist packet or a CameraInstruction including aim assist. + */ + export type packet_camera_aim_assist_presets = { + /** CategoryGroups is a list of groups of categories which can be referenced by one of the Presets. */ + categories: { + /** Identifier is the unique identifier of the group. */ + name: string; + /** Priorities represents the block and entity specific priorities for targetting. The aim assist will select the block or entity with the highest priority within the specified thresholds. */ + entity_priorities: { + id: string; + priority: number; + }[]; + block_priorities: { + id: string; + priority: number; + }[]; + block_tags: number[]; + entity_default?: number; + block_default?: number; + }[]; + presets: { + id: string; + exclusion_settings: { + blocks: string[]; + entities: string[]; + block_tags: string[]; + }; + target_liquids: string[]; + item_settings: { + /** Identifier of the item to apply the settings to. */ + id: string; + /** Category is the identifier of a category to use which has been defined by a CameraAimAssistCategory. */ + category: string; + }[]; + default_item_settings?: string; + hand_settings?: string; + }[]; + operation: 'set' | 'add_to_existing'; + }; + export type packet_client_camera_aim_assist = { + /** !bound: client */ + preset_id: string; + action: 'set_from_camera_preset' | 'clear'; + allow_aim_assist: boolean; + }; + export type packet_client_movement_prediction_sync = { + /** !bound: ? */ + data_flags: number; + bounding_box: { + scale: number; + width: number; + height: number; + }; + movement_speed: number; + underwater_movement_speed: number; + lava_movement_speed: number; + jump_strength: number; + health: number; + hunger: number; + entity_runtime_id: bigint; + is_flying: boolean; + }; + export type packet_update_client_options = { + graphics_mode?: 'simple' | 'fancy' | 'advanced' | 'ray_traced'; + }; + /** + * PlayerVideoCapturePacket is sent by the server to start or stop video recording for a player. This packet + * only works on development builds and has no effect on retail builds. When recording, the client will save + * individual frames to '/LocalCache/minecraftpe' in the format specified below. + */ + export type packet_player_video_capture = { + /** action is the action that the client should perform. This is one of the constants defined above. */ + action: 'stop' | 'start'; + frame_rate?: number; + file_prefix?: string; + }; + /** + * PlayerUpdateEntityOverrides is sent by the server to modify an entity's properties individually. + */ + export type packet_player_update_entity_overrides = { + /** EntityRuntimeID is the runtime ID of the entity. The runtime ID is unique for each world session, and entities are generally identified in packets using this runtime ID. */ + runtime_id: bigint; + /** PropertyIndex is the index of the property to modify. The index is unique for each property of an entity. */ + property_index: number; + /** Type is the type of action to perform with the property. It is one of the constants above. */ + type: 'clear_all' | 'remove' | 'set_int' | 'set_float'; + value?: number; + }; + export type packet_player_location = { + /** Type is the action that is being performed. It is one of the constants above. */ + type: 'coordinates' | 'type_hide'; + entity_unique_id: bigint; + position?: vec3f; + }; + export type packet_clientbound_controls_scheme = { + /** Scheme is the scheme that the client should use. It is one of the constants above. */ + scheme: 'locked_player_relative_strafe' | 'camera_relative' | 'camera_relative_strafe' | 'player_relative' | 'player_relative_strafe'; + }; + /** + * ServerScriptDebugDrawer instructs the client to render debug shapes for development visualisations. + */ + export type packet_server_script_debug_drawer = { + shapes: { + network_id: bigint; + shape_type?: 'line' | 'box' | 'sphere' | 'circle' | 'text' | 'arrow'; + location?: vec3f; + scale?: number; + rotation?: vec3f; + time_left?: number; + color?: number; + text?: string; + box_bound?: vec3f; + line_end_location?: vec3f; + arrow_head_length?: number; + arrow_head_radius?: number; + segment_count?: number; + }[]; + }; + /** + * ServerBoundPackSettingChange + */ + export type packet_serverbound_pack_setting_change = { + pack_id: string; + pack_setting: { + name: string; + type: 'float' | 'bool' | 'string'; + value: number | boolean | string; + }; + }; + export type packet_clientbound_data_store = { + entries: { + type: 'update' | 'change' | 'removal'; + name?: string; + property?: string; + path?: string; + data_type?: 'double' | 'bool' | 'string'; + data?: number | boolean | string; + update_count?: number; + }[]; + }; + export type packet_graphics_override_parameter = { + values: ParameterKeyframeValue[]; + biome_identifier: string; + parameter_type: GraphicsOverrideParameterType; + reset: boolean; + }; + export type packet_serverbound_data_store = { + name: string; + property: string; + path: string; + data_type: 'double' | 'bool' | 'string'; + data: number | boolean | string; + update_count: number; + }; + /** + * Maps packet event names to their corresponding packet types. + * Used for typed event handlers on the Bedrock client. + */ + export type BedrockPacketEventMap = { + login: (packet: packet_login) => void; + play_status: (packet: packet_play_status) => void; + server_to_client_handshake: (packet: packet_server_to_client_handshake) => void; + client_to_server_handshake: (packet: packet_client_to_server_handshake) => void; + disconnect: (packet: packet_disconnect) => void; + resource_packs_info: (packet: packet_resource_packs_info) => void; + resource_pack_stack: (packet: packet_resource_pack_stack) => void; + resource_pack_client_response: (packet: packet_resource_pack_client_response) => void; + text: (packet: packet_text) => void; + set_time: (packet: packet_set_time) => void; + start_game: (packet: packet_start_game) => void; + add_player: (packet: packet_add_player) => void; + add_entity: (packet: packet_add_entity) => void; + remove_entity: (packet: packet_remove_entity) => void; + add_item_entity: (packet: packet_add_item_entity) => void; + server_post_move: (packet: packet_server_post_move) => void; + take_item_entity: (packet: packet_take_item_entity) => void; + move_entity: (packet: packet_move_entity) => void; + move_player: (packet: packet_move_player) => void; + rider_jump: (packet: packet_rider_jump) => void; + update_block: (packet: packet_update_block) => void; + add_painting: (packet: packet_add_painting) => void; + tick_sync: (packet: packet_tick_sync) => void; + level_sound_event_old: (packet: packet_level_sound_event_old) => void; + level_event: (packet: packet_level_event) => void; + block_event: (packet: packet_block_event) => void; + entity_event: (packet: packet_entity_event) => void; + mob_effect: (packet: packet_mob_effect) => void; + update_attributes: (packet: packet_update_attributes) => void; + inventory_transaction: (packet: packet_inventory_transaction) => void; + mob_equipment: (packet: packet_mob_equipment) => void; + mob_armor_equipment: (packet: packet_mob_armor_equipment) => void; + interact: (packet: packet_interact) => void; + block_pick_request: (packet: packet_block_pick_request) => void; + entity_pick_request: (packet: packet_entity_pick_request) => void; + player_action: (packet: packet_player_action) => void; + hurt_armor: (packet: packet_hurt_armor) => void; + set_entity_data: (packet: packet_set_entity_data) => void; + set_entity_motion: (packet: packet_set_entity_motion) => void; + set_entity_link: (packet: packet_set_entity_link) => void; + set_health: (packet: packet_set_health) => void; + set_spawn_position: (packet: packet_set_spawn_position) => void; + animate: (packet: packet_animate) => void; + respawn: (packet: packet_respawn) => void; + container_open: (packet: packet_container_open) => void; + container_close: (packet: packet_container_close) => void; + player_hotbar: (packet: packet_player_hotbar) => void; + inventory_content: (packet: packet_inventory_content) => void; + inventory_slot: (packet: packet_inventory_slot) => void; + container_set_data: (packet: packet_container_set_data) => void; + crafting_data: (packet: packet_crafting_data) => void; + crafting_event: (packet: packet_crafting_event) => void; + gui_data_pick_item: (packet: packet_gui_data_pick_item) => void; + adventure_settings: (packet: packet_adventure_settings) => void; + block_entity_data: (packet: packet_block_entity_data) => void; + player_input: (packet: packet_player_input) => void; + level_chunk: (packet: packet_level_chunk) => void; + set_commands_enabled: (packet: packet_set_commands_enabled) => void; + set_difficulty: (packet: packet_set_difficulty) => void; + change_dimension: (packet: packet_change_dimension) => void; + set_player_game_type: (packet: packet_set_player_game_type) => void; + player_list: (packet: packet_player_list) => void; + simple_event: (packet: packet_simple_event) => void; + event: (packet: packet_event) => void; + spawn_experience_orb: (packet: packet_spawn_experience_orb) => void; + clientbound_map_item_data: (packet: packet_clientbound_map_item_data) => void; + map_info_request: (packet: packet_map_info_request) => void; + request_chunk_radius: (packet: packet_request_chunk_radius) => void; + chunk_radius_update: (packet: packet_chunk_radius_update) => void; + game_rules_changed: (packet: packet_game_rules_changed) => void; + camera: (packet: packet_camera) => void; + boss_event: (packet: packet_boss_event) => void; + show_credits: (packet: packet_show_credits) => void; + available_commands: (packet: packet_available_commands) => void; + command_request: (packet: packet_command_request) => void; + command_block_update: (packet: packet_command_block_update) => void; + command_output: (packet: packet_command_output) => void; + update_trade: (packet: packet_update_trade) => void; + update_equipment: (packet: packet_update_equipment) => void; + resource_pack_data_info: (packet: packet_resource_pack_data_info) => void; + resource_pack_chunk_data: (packet: packet_resource_pack_chunk_data) => void; + resource_pack_chunk_request: (packet: packet_resource_pack_chunk_request) => void; + transfer: (packet: packet_transfer) => void; + play_sound: (packet: packet_play_sound) => void; + stop_sound: (packet: packet_stop_sound) => void; + set_title: (packet: packet_set_title) => void; + add_behavior_tree: (packet: packet_add_behavior_tree) => void; + structure_block_update: (packet: packet_structure_block_update) => void; + show_store_offer: (packet: packet_show_store_offer) => void; + purchase_receipt: (packet: packet_purchase_receipt) => void; + player_skin: (packet: packet_player_skin) => void; + sub_client_login: (packet: packet_sub_client_login) => void; + initiate_web_socket_connection: (packet: packet_initiate_web_socket_connection) => void; + set_last_hurt_by: (packet: packet_set_last_hurt_by) => void; + book_edit: (packet: packet_book_edit) => void; + npc_request: (packet: packet_npc_request) => void; + photo_transfer: (packet: packet_photo_transfer) => void; + modal_form_request: (packet: packet_modal_form_request) => void; + modal_form_response: (packet: packet_modal_form_response) => void; + server_settings_request: (packet: packet_server_settings_request) => void; + server_settings_response: (packet: packet_server_settings_response) => void; + show_profile: (packet: packet_show_profile) => void; + set_default_game_type: (packet: packet_set_default_game_type) => void; + remove_objective: (packet: packet_remove_objective) => void; + set_display_objective: (packet: packet_set_display_objective) => void; + set_score: (packet: packet_set_score) => void; + lab_table: (packet: packet_lab_table) => void; + update_block_synced: (packet: packet_update_block_synced) => void; + move_entity_delta: (packet: packet_move_entity_delta) => void; + set_scoreboard_identity: (packet: packet_set_scoreboard_identity) => void; + set_local_player_as_initialized: (packet: packet_set_local_player_as_initialized) => void; + update_soft_enum: (packet: packet_update_soft_enum) => void; + network_stack_latency: (packet: packet_network_stack_latency) => void; + script_custom_event: (packet: packet_script_custom_event) => void; + spawn_particle_effect: (packet: packet_spawn_particle_effect) => void; + available_entity_identifiers: (packet: packet_available_entity_identifiers) => void; + level_sound_event_v2: (packet: packet_level_sound_event_v2) => void; + network_chunk_publisher_update: (packet: packet_network_chunk_publisher_update) => void; + biome_definition_list: (packet: packet_biome_definition_list) => void; + level_sound_event: (packet: packet_level_sound_event) => void; + level_event_generic: (packet: packet_level_event_generic) => void; + lectern_update: (packet: packet_lectern_update) => void; + video_stream_connect: (packet: packet_video_stream_connect) => void; + client_cache_status: (packet: packet_client_cache_status) => void; + on_screen_texture_animation: (packet: packet_on_screen_texture_animation) => void; + map_create_locked_copy: (packet: packet_map_create_locked_copy) => void; + structure_template_data_export_request: (packet: packet_structure_template_data_export_request) => void; + structure_template_data_export_response: (packet: packet_structure_template_data_export_response) => void; + update_block_properties: (packet: packet_update_block_properties) => void; + client_cache_blob_status: (packet: packet_client_cache_blob_status) => void; + client_cache_miss_response: (packet: packet_client_cache_miss_response) => void; + education_settings: (packet: packet_education_settings) => void; + emote: (packet: packet_emote) => void; + multiplayer_settings: (packet: packet_multiplayer_settings) => void; + settings_command: (packet: packet_settings_command) => void; + anvil_damage: (packet: packet_anvil_damage) => void; + completed_using_item: (packet: packet_completed_using_item) => void; + network_settings: (packet: packet_network_settings) => void; + player_auth_input: (packet: packet_player_auth_input) => void; + creative_content: (packet: packet_creative_content) => void; + player_enchant_options: (packet: packet_player_enchant_options) => void; + item_stack_request: (packet: packet_item_stack_request) => void; + item_stack_response: (packet: packet_item_stack_response) => void; + player_armor_damage: (packet: packet_player_armor_damage) => void; + code_builder: (packet: packet_code_builder) => void; + update_player_game_type: (packet: packet_update_player_game_type) => void; + emote_list: (packet: packet_emote_list) => void; + position_tracking_db_broadcast: (packet: packet_position_tracking_db_broadcast) => void; + position_tracking_db_request: (packet: packet_position_tracking_db_request) => void; + debug_info: (packet: packet_debug_info) => void; + packet_violation_warning: (packet: packet_packet_violation_warning) => void; + motion_prediction_hints: (packet: packet_motion_prediction_hints) => void; + animate_entity: (packet: packet_animate_entity) => void; + camera_shake: (packet: packet_camera_shake) => void; + player_fog: (packet: packet_player_fog) => void; + correct_player_move_prediction: (packet: packet_correct_player_move_prediction) => void; + item_registry: (packet: packet_item_registry) => void; + filter_text_packet: (packet: packet_filter_text_packet) => void; + debug_renderer: (packet: packet_debug_renderer) => void; + sync_entity_property: (packet: packet_sync_entity_property) => void; + add_volume_entity: (packet: packet_add_volume_entity) => void; + remove_volume_entity: (packet: packet_remove_volume_entity) => void; + simulation_type: (packet: packet_simulation_type) => void; + npc_dialogue: (packet: packet_npc_dialogue) => void; + edu_uri_resource_packet: (packet: packet_edu_uri_resource_packet) => void; + create_photo: (packet: packet_create_photo) => void; + update_subchunk_blocks: (packet: packet_update_subchunk_blocks) => void; + photo_info_request: (packet: packet_photo_info_request) => void; + subchunk: (packet: packet_subchunk) => void; + subchunk_request: (packet: packet_subchunk_request) => void; + client_start_item_cooldown: (packet: packet_client_start_item_cooldown) => void; + script_message: (packet: packet_script_message) => void; + code_builder_source: (packet: packet_code_builder_source) => void; + ticking_areas_load_status: (packet: packet_ticking_areas_load_status) => void; + dimension_data: (packet: packet_dimension_data) => void; + agent_action: (packet: packet_agent_action) => void; + change_mob_property: (packet: packet_change_mob_property) => void; + lesson_progress: (packet: packet_lesson_progress) => void; + request_ability: (packet: packet_request_ability) => void; + request_permissions: (packet: packet_request_permissions) => void; + toast_request: (packet: packet_toast_request) => void; + update_abilities: (packet: packet_update_abilities) => void; + update_adventure_settings: (packet: packet_update_adventure_settings) => void; + death_info: (packet: packet_death_info) => void; + editor_network: (packet: packet_editor_network) => void; + feature_registry: (packet: packet_feature_registry) => void; + server_stats: (packet: packet_server_stats) => void; + request_network_settings: (packet: packet_request_network_settings) => void; + game_test_request: (packet: packet_game_test_request) => void; + game_test_results: (packet: packet_game_test_results) => void; + update_client_input_locks: (packet: packet_update_client_input_locks) => void; + client_cheat_ability: (packet: packet_client_cheat_ability) => void; + camera_presets: (packet: packet_camera_presets) => void; + unlocked_recipes: (packet: packet_unlocked_recipes) => void; + camera_instruction: (packet: packet_camera_instruction) => void; + compressed_biome_definitions: (packet: packet_compressed_biome_definitions) => void; + trim_data: (packet: packet_trim_data) => void; + open_sign: (packet: packet_open_sign) => void; + agent_animation: (packet: packet_agent_animation) => void; + refresh_entitlements: (packet: packet_refresh_entitlements) => void; + toggle_crafter_slot_request: (packet: packet_toggle_crafter_slot_request) => void; + set_player_inventory_options: (packet: packet_set_player_inventory_options) => void; + set_hud: (packet: packet_set_hud) => void; + award_achievement: (packet: packet_award_achievement) => void; + clientbound_close_form: (packet: packet_clientbound_close_form) => void; + serverbound_loading_screen: (packet: packet_serverbound_loading_screen) => void; + jigsaw_structure_data: (packet: packet_jigsaw_structure_data) => void; + current_structure_feature: (packet: packet_current_structure_feature) => void; + serverbound_diagnostics: (packet: packet_serverbound_diagnostics) => void; + camera_aim_assist: (packet: packet_camera_aim_assist) => void; + container_registry_cleanup: (packet: packet_container_registry_cleanup) => void; + movement_effect: (packet: packet_movement_effect) => void; + set_movement_authority: (packet: packet_set_movement_authority) => void; + camera_aim_assist_presets: (packet: packet_camera_aim_assist_presets) => void; + client_camera_aim_assist: (packet: packet_client_camera_aim_assist) => void; + client_movement_prediction_sync: (packet: packet_client_movement_prediction_sync) => void; + update_client_options: (packet: packet_update_client_options) => void; + player_video_capture: (packet: packet_player_video_capture) => void; + player_update_entity_overrides: (packet: packet_player_update_entity_overrides) => void; + player_location: (packet: packet_player_location) => void; + clientbound_controls_scheme: (packet: packet_clientbound_controls_scheme) => void; + server_script_debug_drawer: (packet: packet_server_script_debug_drawer) => void; + serverbound_pack_setting_change: (packet: packet_serverbound_pack_setting_change) => void; + clientbound_data_store: (packet: packet_clientbound_data_store) => void; + graphics_override_parameter: (packet: packet_graphics_override_parameter) => void; + serverbound_data_store: (packet: packet_serverbound_data_store) => void; + }; +} diff --git a/bridge/lib/mineflayer/index.d.ts b/bridge/lib/mineflayer/index.d.ts new file mode 100644 index 0000000..7cc2801 --- /dev/null +++ b/bridge/lib/mineflayer/index.d.ts @@ -0,0 +1,878 @@ +import { EventEmitter } from 'events'; +import TypedEmitter from 'typed-emitter'; +import { Client, ClientOptions } from 'minecraft-protocol'; +import { Vec3 } from 'vec3'; +import { Item } from 'prismarine-item'; +import { Window } from 'prismarine-windows'; +import { Recipe } from 'prismarine-recipe'; +import { Block } from 'prismarine-block'; +import { Entity } from 'prismarine-entity'; + +// Extend Entity interface for Bedrock-specific properties +declare module 'prismarine-entity' { + interface Entity { + // Bedrock-specific entity properties + attributes?: Record< + string, + { + current: number; + value?: number; + max?: number; + min?: number; + default?: number; + } + >; + unique_id?: bigint; + nametag?: string; + isInWater?: boolean; + isCollidedVertically?: boolean; + isCollidedHorizontally?: boolean; + headYaw?: number; + timeSinceOnGround?: number; + } +} +import { ChatMessage } from 'prismarine-chat'; +import { world } from 'prismarine-world'; +import { Registry } from 'prismarine-registry'; + +// Extend Registry interface for Bedrock-specific properties +declare module 'prismarine-registry' { + interface Registry { + // Bedrock-specific registry properties + blocksByRuntimeId?: Record; + dimensionsByName?: Record; + handleItemRegistry?: (packet: any) => void; + handleStartGame?: (packet: any) => void; + } +} +import { IndexedData } from 'minecraft-data'; + +export function createBot(options: BotOptions): Bot; +export function createBot(options: { client: Client } & Partial): Bot; + +// Logger exports +export { Logger, LogLevel, LoggerColors } from './lib/logger/logger.mts'; + +export interface BotOptions extends ClientOptions { + logErrors?: boolean; + hideErrors?: boolean; + loadInternalPlugins?: boolean; + plugins?: PluginOptions; + chat?: ChatLevel; + colorsEnabled?: boolean; + viewDistance?: ViewDistance; + mainHand?: MainHands; + difficulty?: number; + chatLengthLimit?: number; + physicsEnabled?: boolean; + /** @default 4 */ + maxCatchupTicks?: number; + client?: Client; + brand?: string; + defaultChatPatterns?: boolean; + respawn?: boolean; + /** Enable debug logging */ + debug?: boolean; + /** Set the log level (overrides debug option) */ + logLevel?: LogLevel; +} + +export type ChatLevel = 'enabled' | 'commandsOnly' | 'disabled'; +export type ViewDistance = 'far' | 'normal' | 'short' | 'tiny' | number; +export type MainHands = 'left' | 'right'; + +export interface PluginOptions { + [plugin: string]: boolean | Plugin; +} + +export type Plugin = (bot: Bot, options: BotOptions) => void; + +export interface BotEvents { + chat: (username: string, message: string, translate: string | null, jsonMsg: ChatMessage, matches: string[] | null) => Promise | void; + whisper: (username: string, message: string, translate: string | null, jsonMsg: ChatMessage, matches: string[] | null) => Promise | void; + actionBar: (jsonMsg: ChatMessage) => Promise | void; + error: (err: Error) => Promise | void; + message: (jsonMsg: ChatMessage, position: string) => Promise | void; + messagestr: (message: string, position: string, jsonMsg: ChatMessage) => Promise | void; + unmatchedMessage: (stringMsg: string, jsonMsg: ChatMessage) => Promise | void; + inject_allowed: () => Promise | void; + login: () => Promise | void; + /** When `respawn` option is disabled, you can call this method manually to respawn. */ + spawn: () => Promise | void; + respawn: () => Promise | void; + game: () => Promise | void; + title: (text: string, type: 'subtitle' | 'title') => Promise | void; + rain: () => Promise | void; + time: () => Promise | void; + kicked: (reason: string, loggedIn: boolean) => Promise | void; + end: (reason: string) => Promise | void; + spawnReset: () => Promise | void; + death: () => Promise | void; + health: () => Promise | void; + breath: () => Promise | void; + entitySwingArm: (entity: Entity) => Promise | void; + entityHurt: (entity: Entity, source: Entity) => Promise | void; + entityDead: (entity: Entity) => Promise | void; + entityTaming: (entity: Entity) => Promise | void; + entityTamed: (entity: Entity) => Promise | void; + entityShakingOffWater: (entity: Entity) => Promise | void; + entityEatingGrass: (entity: Entity) => Promise | void; + entityHandSwap: (entity: Entity) => Promise | void; + entityWake: (entity: Entity) => Promise | void; + entityEat: (entity: Entity) => Promise | void; + entityCriticalEffect: (entity: Entity) => Promise | void; + entityMagicCriticalEffect: (entity: Entity) => Promise | void; + entityCrouch: (entity: Entity) => Promise | void; + entityUncrouch: (entity: Entity) => Promise | void; + entityEquip: (entity: Entity) => Promise | void; + entitySleep: (entity: Entity) => Promise | void; + entitySpawn: (entity: Entity) => Promise | void; + entityElytraFlew: (entity: Entity) => Promise | void; + usedFirework: () => Promise | void; + itemDrop: (entity: Entity) => Promise | void; + playerCollect: (collector: Entity, collected: Entity) => Promise | void; + entityAttributes: (entity: Entity) => Promise | void; + entityGone: (entity: Entity) => Promise | void; + entityMoved: (entity: Entity) => Promise | void; + entityDetach: (entity: Entity, vehicle: Entity) => Promise | void; + entityAttach: (entity: Entity, vehicle: Entity) => Promise | void; + entityUpdate: (entity: Entity) => Promise | void; + entityEffect: (entity: Entity, effect: Effect) => Promise | void; + entityEffectEnd: (entity: Entity, effect: Effect) => Promise | void; + playerJoined: (player: Player) => Promise | void; + playerUpdated: (player: Player) => Promise | void; + playerLeft: (entity: Player) => Promise | void; + blockUpdate: (oldBlock: Block | null, newBlock: Block) => Promise | void; + 'blockUpdate:(x, y, z)': (oldBlock: Block | null, newBlock: Block | null) => Promise | void; + chunkColumnLoad: (entity: Vec3) => Promise | void; + chunkColumnUnload: (entity: Vec3) => Promise | void; + soundEffectHeard: (soundName: string, position: Vec3, volume: number, pitch: number) => Promise | void; + hardcodedSoundEffectHeard: (soundId: number, soundCategory: number, position: Vec3, volume: number, pitch: number) => Promise | void; + noteHeard: (block: Block, instrument: Instrument, pitch: number) => Promise | void; + pistonMove: (block: Block, isPulling: number, direction: number) => Promise | void; + chestLidMove: (block: Block, isOpen: number | boolean, block2: Block | null) => Promise | void; + blockBreakProgressObserved: (block: Block, destroyStage: number, entity?: Entity) => Promise | void; + blockBreakProgressEnd: (block: Block, entity?: Entity) => Promise | void; + diggingCompleted: (block: Block) => Promise | void; + diggingAborted: (block: Block) => Promise | void; + move: (position: Vec3) => Promise | void; + forcedMove: () => Promise | void; + mount: () => Promise | void; + dismount: (vehicle: Entity) => Promise | void; + windowOpen: (window: Window) => Promise | void; + windowClose: (window: Window) => Promise | void; + sleep: () => Promise | void; + wake: () => Promise | void; + experience: () => Promise | void; + physicsTick: () => Promise | void; + physicTick: () => Promise | void; + scoreboardCreated: (scoreboard: ScoreBoard) => Promise | void; + scoreboardDeleted: (scoreboard: ScoreBoard) => Promise | void; + scoreboardTitleChanged: (scoreboard: ScoreBoard) => Promise | void; + scoreUpdated: (scoreboard: ScoreBoard, item: number) => Promise | void; + scoreRemoved: (scoreboard: ScoreBoard, item: number) => Promise | void; + scoreboardPosition: (position: DisplaySlot, scoreboard: ScoreBoard) => Promise | void; + teamCreated: (team: Team) => Promise | void; + teamRemoved: (team: Team) => Promise | void; + teamUpdated: (team: Team) => Promise | void; + teamMemberAdded: (team: Team) => Promise | void; + teamMemberRemoved: (team: Team) => Promise | void; + bossBarCreated: (bossBar: BossBar) => Promise | void; + bossBarDeleted: (bossBar: BossBar) => Promise | void; + bossBarUpdated: (bossBar: BossBar) => Promise | void; + resourcePack: (url: string, hash?: string, uuid?: string) => Promise | void; + particle: (particle: Particle) => Promise | void; + heldItemChanged: (heldItem: Item | null) => void; + + // Node.js EventEmitter events + newListener: (eventName: string, listener: Function) => void; + removeListener: (eventName: string, listener: Function) => void; + + // Bedrock-specific events + update_attributes: (entity: Entity, attributes: any[]) => void; + playerMoved: (player: Player) => void; + weatherUpdate: () => void; + subchunkContainingPlayerChanged: (oldChunk: Vec3, newChunk: Vec3) => void; + + // Dynamic event patterns (use string index signature for these) + [key: `chat:${string}`]: (username: string, message: string, translate: string | null, jsonMsg: ChatMessage, matches: string[] | null) => void; + [key: `setWindowItems:${number}`]: (window: Window) => void; + [key: `itemStackResponse:${number}`]: (response: any) => void; +} + +export interface CommandBlockOptions { + mode: number; + trackOutput: boolean; + conditional: boolean; + alwaysActive: boolean; +} + +export interface Bot extends TypedEmitter { + username: string; + protocolVersion: string; + majorVersion: string; + version: string; + entity: Entity; + entities: { [id: string]: Entity }; + fireworkRocketDuration: number; + spawnPoint: Vec3; + game: GameState; + player: Player; + players: { [username: string]: Player }; + isRaining: boolean; + thunderState: number; + chatPatterns: ChatPattern[]; + settings: GameSettings; + experience: Experience; + health: number; + food: number; + foodSaturation: number; + oxygenLevel: number; + physics: PhysicsOptions; + physicsEnabled: boolean; + time: Time; + quickBarSlot: number; + inventory: Window; + targetDigBlock: Block; + isSleeping: boolean; + scoreboards: { [name: string]: ScoreBoard }; + scoreboard: { [slot in DisplaySlot]: ScoreBoard }; + teams: { [name: string]: Team }; + teamMap: { [name: string]: Team }; + controlState: ControlStateStatus; + creative: creativeMethods; + world: world.WorldSync; + _client: Client; + heldItem: Item | null; + usingHeldItem: boolean; + currentWindow: Window | null; + simpleClick: simpleClick; + tablist: Tablist; + registry: Registry; + logger: import('./lib/logger/logger.mts').Logger; + + connect: (options: BotOptions) => void; + + supportFeature: IndexedData['supportFeature']; + + end: (reason?: string) => void; + + blockAt: (point: Vec3, extraInfos?: boolean) => Block | null; + + blockInSight: (maxSteps: number, vectorLength: number) => Block | null; + + blockAtCursor: (maxDistance?: number, matcher?: Function) => Block | null; + blockAtEntityCursor: (entity?: Entity, maxDistance?: number, matcher?: Function) => Block | null; + + canSeeBlock: (block: Block) => boolean; + + findBlock: (options: FindBlockOptions) => Block | null; + + findBlocks: (options: FindBlockOptions) => Vec3[]; + + canDigBlock: (block: Block) => boolean; + + recipesFor: (itemType: number, metadata: number | null, minResultCount: number | null, craftingTable: Block | boolean | null) => Recipe[]; + + recipesAll: (itemType: number, metadata: number | null, craftingTable: Block | boolean | null) => Recipe[]; + + quit: (reason?: string) => void; + + tabComplete: (str: string, assumeCommand?: boolean, sendBlockInSight?: boolean, timeout?: number) => Promise; + + chat: (message: string) => void; + + whisper: (username: string, message: string) => void; + + chatAddPattern: (pattern: RegExp, chatType: string, description?: string) => number; + + setSettings: (options: Partial) => void; + + loadPlugin: (plugin: Plugin) => void; + + loadPlugins: (plugins: Plugin[]) => void; + + hasPlugin: (plugin: Plugin) => boolean; + + sleep: (bedBlock: Block) => Promise; + + isABed: (bedBlock: Block) => boolean; + + wake: () => Promise; + + elytraFly: () => Promise; + + setControlState: (control: ControlState, state: boolean) => void; + + getControlState: (control: ControlState) => boolean; + + clearControlStates: () => void; + + getExplosionDamages: (targetEntity: Entity, position: Vec3, radius: number, rawDamages?: boolean) => number | null; + + lookAt: (point: Vec3, force?: boolean) => Promise; + + look: (yaw: number, pitch: number, force?: boolean) => Promise; + + updateSign: (block: Block, text: string, back?: boolean) => void; + + equip: (item: Item | number, destination: EquipmentDestination | null) => Promise; + + unequip: (destination: EquipmentDestination | null) => Promise; + + tossStack: (item: Item) => Promise; + + toss: (itemType: number, metadata: number | null, count: number | null) => Promise; + + dig: ((block: Block, forceLook?: boolean | 'ignore') => Promise) & ((block: Block, forceLook: boolean | 'ignore', digFace: 'auto' | Vec3 | 'raycast') => Promise); + + stopDigging: () => void; + + digTime: (block: Block) => number; + + placeBlock: (referenceBlock: Block, faceVector: Vec3) => Promise; + + placeEntity: (referenceBlock: Block, faceVector: Vec3) => Promise; + + activateBlock: (block: Block, direction?: Vec3, cursorPos?: Vec3) => Promise; + + activateEntity: (entity: Entity) => Promise; + + activateEntityAt: (entity: Entity, position: Vec3) => Promise; + + consume: () => Promise; + + fish: () => Promise; + + activateItem: (offhand?: boolean) => void; + + deactivateItem: () => void; + + useOn: (targetEntity: Entity) => void; + + attack: (entity: Entity) => void; + + swingArm: (hand: 'left' | 'right' | undefined, showHand?: boolean) => void; + + mount: (entity: Entity) => void; + + dismount: () => void; + + moveVehicle: (left: number, forward: number) => void; + + setQuickBarSlot: (slot: number) => void; + + craft: (recipe: Recipe, count?: number, craftingTable?: Block) => Promise; + + writeBook: (slot: number, pages: string[]) => Promise; + + openContainer: (chest: Block | Entity, direction?: Vec3, cursorPos?: Vec3) => Promise; + + openChest: (chest: Block | Entity, direction?: number, cursorPos?: Vec3) => Promise; + + openFurnace: (furnace: Block) => Promise; + + openDispenser: (dispenser: Block) => Promise; + + openEnchantmentTable: (enchantmentTable: Block) => Promise; + + openAnvil: (anvil: Block) => Promise; + + openVillager: (villager: Entity) => Promise; + + trade: (villagerInstance: Villager, tradeIndex: string | number, times?: number) => Promise; + + setCommandBlock: (pos: Vec3, command: string, options: CommandBlockOptions) => void; + + clickWindow: (slot: number, mouseButton: number, mode: number) => Promise; + + putSelectedItemRange: (start: number, end: number, window: Window, slot: any) => Promise; + + putAway: (slot: number) => Promise; + + closeWindow: (window: Window) => void; + + transfer: (options: TransferOptions) => Promise; + + openBlock: (block: Block, direction?: Vec3, cursorPos?: Vec3) => Promise; + + openEntity: (block: Entity, Class: new () => EventEmitter) => Promise; + + openInventory: () => Promise; + + moveSlotItem: (sourceSlot: number, destSlot: number) => Promise; + + updateHeldItem: () => void; + + getEquipmentDestSlot: (destination: string) => number; + + getNextItemStackRequestId: () => number; + + waitForChunksToLoad: () => Promise; + + entityAtCursor: (maxDistance?: number) => Entity | null; + nearestEntity: (filter?: (entity: Entity) => boolean) => Entity | null; + + waitForTicks: (ticks: number) => Promise; + + addChatPattern: (name: string, pattern: RegExp, options?: chatPatternOptions) => number; + + addChatPatternSet: (name: string, patterns: RegExp[], options?: chatPatternOptions) => number; + + removeChatPattern: (name: string | number) => void; + + awaitMessage: (...args: string[] | RegExp[]) => Promise; + + acceptResourcePack: () => void; + + denyResourcePack: () => void; + + respawn: () => void; + + close: () => void; + + cameraState: { pitch: number; yaw: number }; + + item_registry_task: { promise: Promise } | null; +} + +/** + * Bedrock client with typed packet event handlers. + * Use this for explicit typing when you need typed packet events. + */ +export interface BedrockClient extends TypedEmitter void; join: () => void }> { + username: string; + + write(name: K, params: Parameters[0]): void; + + queue(name: K, params: Parameters[0]): void; +} + +/** + * Bedrock bot with typed client. + * Use this instead of Bot when working with Bedrock Edition for typed packet events. + * + * @example + * ```typescript + * import { createBot, BedrockBot } from 'mineflayer'; + * + * const bot = createBot({ ... }) as BedrockBot; + * + * bot._client.on('text', (packet) => { + * console.log(packet.message); // Fully typed! + * }); + * ``` + */ +export interface BedrockBot extends Bot { + _client: BedrockClient; + + // Bedrock-specific properties + isAlive: boolean; + rainState: number; + jumpQueued: boolean; + jumpTicks: number; + selectedSlot: number; + QUICK_BAR_START: number; + + // Bedrock-specific mappings + uuidToUsername: Record; + + // Bedrock-specific methods + findPlayer: (filter: (player: Player) => boolean) => Player | null; + findPlayers: (filter: (player: Player) => boolean) => Player[]; + _playerFromUUID: (uuid: string) => Player | null; + + // Digging state + targetDigFace: Vec3 | null; + lastDigTime: number; + + // Block update method + _updateBlockState: (position: Vec3, stateId: number) => void; + + // Player auth input transaction + sendPlayerAuthInputTransaction: (transaction: any) => void; + + // Item use + useItem: () => void; +} + +export interface simpleClick { + leftMouse: (slot: number) => Promise; + rightMouse: (slot: number) => Promise; +} + +export interface Tablist { + header: ChatMessage; + footer: ChatMessage; +} + +export interface chatPatternOptions { + repeat?: boolean; + parse?: boolean; + deprecated?: boolean; +} + +export interface GameState { + levelType: LevelType; + gameMode: GameMode; + hardcore: boolean; + dimension: Dimension; + difficulty: Difficulty; + maxPlayers: number; + serverBrand: string; + /** Minimum Y coordinate of the world (Bedrock) */ + minY: number; + /** Height of the world (Bedrock) */ + height: number; +} + +export type LevelType = 'default' | 'flat' | 'largeBiomes' | 'amplified' | 'customized' | 'buffet' | 'default_1_1'; +export type GameMode = 'survival' | 'creative' | 'adventure' | 'spectator'; +export type Dimension = 'the_nether' | 'overworld' | 'the_end'; +export type Difficulty = 'peaceful' | 'easy' | 'normal' | 'hard'; + +export interface Player { + uuid: string; + username: string; + displayName: ChatMessage; + gamemode: number; + ping: number; + entity: Entity; + skinData: SkinData | undefined; + profileKeys?: { + publicKey: Buffer; + signature: Buffer; + }; +} + +export interface SkinData { + url: string; + model: string | null; +} + +export interface ChatPattern { + pattern: RegExp; + type: string; + description: string; +} + +export interface SkinParts { + showCape: boolean; + showJacket: boolean; + showLeftSleeve: boolean; + showRightSleeve: boolean; + showLeftPants: boolean; + showRightPants: boolean; + showHat: boolean; +} + +export interface GameSettings { + chat: ChatLevel; + colorsEnabled: boolean; + viewDistance: ViewDistance; + difficulty: number; + skinParts: SkinParts; + mainHand: MainHands; +} + +export interface Experience { + level: number; + points: number; + progress: number; +} + +export interface PhysicsOptions { + maxGroundSpeed: number; + terminalVelocity: number; + walkingAcceleration: number; + gravity: number; + groundFriction: number; + playerApothem: number; + playerHeight: number; + jumpSpeed: number; + yawSpeed: number; + pitchSpeed: number; + sprintSpeed: number; + maxGroundSpeedSoulSand: number; + maxGroundSpeedWater: number; +} + +export interface Time { + doDaylightCycle: boolean; + bigTime: BigInt; + time: number; + timeOfDay: number; + day: number; + isDay: boolean; + moonPhase: number; + bigAge: BigInt; + age: number; +} + +export interface ControlStateStatus { + forward: boolean; + back: boolean; + left: boolean; + right: boolean; + jump: boolean; + sprint: boolean; + sneak: boolean; +} + +export type ControlState = 'forward' | 'back' | 'left' | 'right' | 'jump' | 'sprint' | 'sneak'; + +export interface Effect { + id: number; + amplifier: number; + duration: number; +} + +export interface Instrument { + id: number; + name: string; +} + +export interface FindBlockOptions { + point?: Vec3; + matching: number | number[] | ((block: Block) => boolean); + maxDistance?: number; + count?: number; + useExtraInfo?: boolean | ((block: Block) => boolean); +} + +export type EquipmentDestination = 'hand' | 'head' | 'torso' | 'legs' | 'feet' | 'off-hand'; + +export interface TransferOptions { + window: Window; + itemType: number; + metadata: number | null; + count?: number; + sourceStart: number; + sourceEnd: number; + destStart: number; + destEnd: number; +} + +export interface creativeMethods { + setInventorySlot: (slot: number, item: Item | null) => Promise; + + clearSlot: (slot: number) => Promise; + + clearInventory: () => Promise; + + flyTo: (destination: Vec3) => Promise; + + startFlying: () => void; + + stopFlying: () => void; +} + +export class Location { + floored: Vec3; + blockPoint: Vec3; + chunkCorner: Vec3; + blockIndex: number; + biomeBlockIndex: number; + chunkYIndex: number; + + constructor(absoluteVector: Vec3); +} + +export class Painting { + id: number; + position: Vec3; + name: string; + direction: Vec3; + + constructor(id: number, position: Vec3, name: string, direction: Vec3); +} + +interface StorageEvents { + open: () => void; + close: () => void; + updateSlot: (slot: number, oldItem: Item | null, newItem: Item | null) => void; +} + +interface FurnaceEvents extends StorageEvents { + update: () => void; +} + +interface ConditionalStorageEvents extends StorageEvents { + ready: () => void; +} + +export class Chest extends Window { + constructor(); + + close(): void; + + deposit(itemType: number, metadata: number | null, count: number | null): Promise; + + withdraw(itemType: number, metadata: number | null, count: number | null): Promise; +} + +export class Furnace extends Window { + fuel: number; + progress: number; + + constructor(); + + close(): void; + + takeInput(): Promise; + + takeFuel(): Promise; + + takeOutput(): Promise; + + putInput(itemType: number, metadata: number | null, count: number): Promise; + + putFuel(itemType: number, metadata: number | null, count: number): Promise; + + inputItem(): Item; + + fuelItem(): Item; + + outputItem(): Item; +} + +export class Dispenser extends Window { + constructor(); + + close(): void; + + deposit(itemType: number, metadata: number | null, count: number | null): Promise; + + withdraw(itemType: number, metadata: number | null, count: number | null): Promise; +} + +export class EnchantmentTable extends Window { + enchantments: Enchantment[]; + + constructor(); + + close(): void; + + targetItem(): Item; + + enchant(choice: string | number): Promise; + + takeTargetItem(): Promise; + + putTargetItem(item: Item): Promise; + + putLapis(item: Item): Promise; +} + +export class Anvil { + combine(itemOne: Item, itemTwo: Item, name?: string): Promise; + rename(item: Item, name?: string): Promise; +} + +export interface Enchantment { + level: number; + expected: { enchant: number; level: number }; +} + +export class Villager extends Window { + trades: VillagerTrade[]; + + constructor(); + + close(): void; +} + +export interface VillagerTrade { + inputItem1: Item; + outputItem: Item; + inputItem2: Item | null; + hasItem2: boolean; + tradeDisabled: boolean; + nbTradeUses: number; + maximumNbTradeUses: number; + xp?: number; + specialPrice?: number; + priceMultiplier?: number; + demand?: number; + realPrice?: number; +} + +export class ScoreBoard { + name: string; + title: string; + itemsMap: { [name: string]: ScoreBoardItem }; + items: ScoreBoardItem[]; + + constructor(packet: object); + + setTitle(title: string): void; + + add(name: string, value: number): ScoreBoardItem; + + remove(name: string): ScoreBoardItem; +} + +export interface ScoreBoardItem { + name: string; + displayName: ChatMessage; + value: number; +} + +export class Team { + team: string; + name: ChatMessage; + friendlyFire: number; + nameTagVisibility: string; + collisionRule: string; + color: string; + prefix: ChatMessage; + suffix: ChatMessage; + memberMap: { [name: string]: '' }; + members: string[]; + + constructor(team: string, name: string, friendlyFire: boolean, nameTagVisibility: string, collisionRule: string, formatting: number, prefix: string, suffix: string); + + parseMessage(value: string): ChatMessage; + + add(name: string, value: number): void; + + remove(name: string): void; + + update(name: string, friendlyFire: boolean, nameTagVisibility: string, collisionRule: string, formatting: number, prefix: string, suffix: string): void; + + displayName(member: string): ChatMessage; +} + +export type DisplaySlot = 'list' | 'sidebar' | 'belowName' | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18; + +export class BossBar { + entityUUID: string; + title: ChatMessage; + health: number; + dividers: number; + color: 'pink' | 'blue' | 'red' | 'green' | 'yellow' | 'purple' | 'white'; + shouldDarkenSky: boolean; + isDragonBar: boolean; + createFog: boolean; + shouldCreateFog: boolean; + + constructor(uuid: string, title: string, health: number, dividers: number, color: number, flags: number); +} + +export class Particle { + id: number; + position: Vec3; + offset: Vec3; + count: number; + movementSpeed: number; + longDistanceRender: boolean; + static fromNetwork(packet: Object): Particle; + + constructor(id: number, position: Vec3, offset: Vec3, count?: number, movementSpeed?: number, longDistanceRender?: boolean); +} + +export let testedVersions: string[]; +export let latestSupportedVersion: string; +export let oldestSupportedVersion: string; + +export function supportFeature(feature: string, version: string): boolean; diff --git a/bridge/lib/mineflayer/index.js b/bridge/lib/mineflayer/index.js new file mode 100644 index 0000000..cb4f1ca --- /dev/null +++ b/bridge/lib/mineflayer/index.js @@ -0,0 +1,7 @@ +if (typeof process !== 'undefined' && !process.browser && process.platform !== 'browser' && parseInt(process.versions.node.split('.')[0]) < 18) { + console.error('Your node version is currently', process.versions.node) + console.error('Please update it to a version >= 22.x.x from https://nodejs.org/') + process.exit(1) +} + +module.exports = require('./lib/loader.js') diff --git a/bridge/lib/mineflayer/lib/BlobStore.js b/bridge/lib/mineflayer/lib/BlobStore.js new file mode 100644 index 0000000..b81c064 --- /dev/null +++ b/bridge/lib/mineflayer/lib/BlobStore.js @@ -0,0 +1,54 @@ +class BlobStore extends Map { + pending = {} + wanted = [] + + set(key, value) { + const ret = super.set(key.toString(), value) + this.wanted.forEach(wanted => wanted[0] = wanted[0].filter(hash => hash.toString() !== key.toString())) + for (const i in this.wanted) { + const [outstandingBlobs, cb] = this.wanted[i] + if (!outstandingBlobs.length) { + cb() + delete this.wanted[i] + } + } + return ret + } + + get(key) { + return super.get(key.toString()) + } + + has(key) { + return super.has(key.toString()) + } + + addPending(hash, blob) { + this.pending[hash.toString()] = blob + } + + updatePending(hash, value) { + const name = hash.toString() + if (this.pending[name]) { + this.set(name, Object.assign(this.pending[name], value)) + } else { + throw new Error('No pending blob for hash ' + name) + } + // todo: remove from pending + } + + once(wantedBlobs, cb) { + const outstanding = [] + for (const wanted of wantedBlobs) { + if (!this.has(wanted)) outstanding.push(wanted) + } + + if (outstanding.length) { + this.wanted.push([outstanding, cb]) + } else { + cb() + } + } +} + +module.exports = BlobStore \ No newline at end of file diff --git a/bridge/lib/mineflayer/lib/bedrock/container.mts b/bridge/lib/mineflayer/lib/bedrock/container.mts new file mode 100644 index 0000000..fdb53f1 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/container.mts @@ -0,0 +1,301 @@ +/** + * Container Operations - Unified transfer utilities for Bedrock protocol + * + * Provides: + * - Generic transferItems() function that handles both deposit and withdraw + * - depositToContainer() - Move items from player inventory to container + * - withdrawFromContainer() - Move items from container to player inventory + * + * Replaces ~300 lines of duplicated code in inventory.mts + */ + +import type { Window } from 'prismarine-windows'; +import type { Item } from 'prismarine-item'; +import type { BedrockBot } from '../../index.js'; +import type { protocolTypes } from '../../bedrock-types.js'; + +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds, cursor, type SlotLocation } from './item-stack-actions.mts'; +import { getContainerFromSlot, SlotRanges } from './slot-mapping.mts'; + +// ============================================================================ +// Transfer Configuration +// ============================================================================ + +/** + * Configuration for generic transfer operation + */ +export interface TransferConfig { + bot: BedrockBot; + sourceWindow: Window; + sourceContainerId: string; + sourceStart: number; + sourceEnd: number; + destWindow: Window; + destContainerId: string; + destStart: number; + destEnd: number; + itemType: number; + metadata: number | null; + nbt?: object | null; + count: number | null; +} + +// ============================================================================ +// Generic Transfer Function +// ============================================================================ + +/** + * Transfer items between windows/containers + * + * This is the core transfer function that unifies deposit and withdraw operations. + * Uses two-step cursor-based transfer (take to cursor, place from cursor). + * + * @param config - Transfer configuration + * @returns Number of items transferred + */ +export async function transferItems(config: TransferConfig): Promise { + const { bot, sourceWindow, sourceContainerId, sourceStart, sourceEnd, destWindow, destContainerId, destStart, destEnd, itemType, metadata, nbt, count } = config; + + const totalToTransfer = count ?? 64; + let transferred = 0; + const maxStackSize = bot.registry.itemsArray.find((x: any) => x.id === itemType)?.stackSize ?? 64; + + while (transferred < totalToTransfer) { + // Find source item (re-find each iteration as slots change) + const sourceItem = sourceWindow.findItemRange(sourceStart, sourceEnd, itemType, metadata, false, nbt); + if (!sourceItem) { + if (transferred === 0) { + const mcDataEntry = bot.registry.itemsArray.find((x: any) => x.id === itemType); + throw new Error(`Can't find ${mcDataEntry?.name || itemType} in source`); + } + break; // No more items to transfer + } + + // Find destination slot + let destSlot: number | null = null; + let destStackId = 0; + let destCurrentCount = 0; + + // First try to stack with existing items + const existingItem = destWindow.findItemRange(destStart, destEnd, itemType, metadata, true, nbt); + if (existingItem) { + destSlot = existingItem.slot; + destStackId = getStackId(existingItem); + destCurrentCount = existingItem.count; + } else { + destSlot = destWindow.firstEmptySlotRange(destStart, destEnd); + destCurrentCount = 0; + } + + if (destSlot === null) { + if (transferred === 0) { + throw new Error('Destination is full'); + } + break; // Destination full, but we transferred some + } + + // Calculate how many items to transfer in this batch + const availableInDest = maxStackSize - destCurrentCount; + const remainingToTransfer = totalToTransfer - transferred; + const availableInSource = sourceItem.count; + const transferCount = Math.min(availableInDest, remainingToTransfer, availableInSource); + + if (transferCount <= 0) { + // Destination stack is full, find another slot + destSlot = destWindow.firstEmptySlotRange(destStart, destEnd); + if (destSlot === null) { + break; // Destination full + } + destStackId = 0; + destCurrentCount = 0; + continue; + } + + const stackId = getStackId(sourceItem); + + // Determine source slot info based on container type + let sourceSlot: SlotLocation; + if (sourceContainerId === ContainerIds.CONTAINER) { + sourceSlot = { containerId: ContainerIds.CONTAINER, slot: sourceItem.slot, stackId }; + } else { + // Player inventory - use proper container mapping + const mapped = getContainerFromSlot(sourceItem.slot, sourceWindow); + sourceSlot = { containerId: mapped.containerId, slot: mapped.slot, stackId }; + } + + // Step 1: Take items to cursor + const takeRequestId = getNextItemStackRequestId(); + let cursorStackId = stackId; + + // Capture cursor stack ID from response + const takeResponseHandler = (packet: protocolTypes.packet_item_stack_response) => { + for (const response of packet.responses) { + if (response.request_id === takeRequestId && response.status === 'ok') { + for (const container of response.containers || []) { + if (container.slot_type?.container_id === 'cursor' && container.slots?.length > 0) { + cursorStackId = container.slots[0].item_stack_id; + } + } + } + } + }; + bot._client.on('item_stack_response', takeResponseHandler); + + sendRequest(bot, takeRequestId, actions().takeToCursor(transferCount, sourceSlot).build()); + + let success = await waitForResponse(bot, takeRequestId); + bot._client.removeListener('item_stack_response', takeResponseHandler); + + if (!success) { + throw new Error('Transfer failed - take rejected'); + } + + // Step 2: Place items from cursor to destination + const placeRequestId = getNextItemStackRequestId(); + + // Determine destination slot info + let destSlotInfo: SlotLocation; + if (destContainerId === ContainerIds.CONTAINER) { + destSlotInfo = { containerId: ContainerIds.CONTAINER, slot: destSlot, stackId: destStackId }; + } else { + // Player inventory - use proper container mapping + const mapped = getContainerFromSlot(destSlot, destWindow); + destSlotInfo = { containerId: mapped.containerId, slot: mapped.slot, stackId: destStackId }; + } + + sendRequest(bot, placeRequestId, actions().placeFromCursor(transferCount, cursorStackId, destSlotInfo).build()); + + success = await waitForResponse(bot, placeRequestId); + + if (!success) { + throw new Error('Transfer failed - place rejected'); + } + + transferred += transferCount; + + // Create new item in destination slot if needed + // (item_stack_response doesn't know item type, so we create it manually) + const existingDestItem = destWindow.slots[destSlot]; + if (!existingDestItem) { + const newItem = Object.assign(Object.create(Object.getPrototypeOf(sourceItem)), sourceItem); + newItem.count = transferCount; + newItem.slot = destSlot; + (newItem as any).stackId = destStackId; + destWindow.updateSlot(destSlot, newItem); + } + } + + return transferred; +} + +// ============================================================================ +// Convenience Functions +// ============================================================================ + +/** + * Deposit items from player inventory to container + */ +export async function depositToContainer( + bot: BedrockBot, + containerWindow: Window, + itemType: number, + metadata: number | null, + count: number | null, + containerSlots: number, + nbt?: object | null +): Promise { + return transferItems({ + bot, + sourceWindow: bot.inventory, + sourceContainerId: ContainerIds.HOTBAR_AND_INVENTORY, + sourceStart: 0, + sourceEnd: SlotRanges.INVENTORY_END, + destWindow: containerWindow, + destContainerId: ContainerIds.CONTAINER, + destStart: 0, + destEnd: containerSlots - 1, + itemType, + metadata, + nbt, + count, + }); +} + +/** + * Withdraw items from container to player inventory + */ +export async function withdrawFromContainer( + bot: BedrockBot, + containerWindow: Window, + itemType: number, + metadata: number | null, + count: number | null, + containerSlots: number, + nbt?: object | null +): Promise { + return transferItems({ + bot, + sourceWindow: containerWindow, + sourceContainerId: ContainerIds.CONTAINER, + sourceStart: 0, + sourceEnd: containerSlots - 1, + destWindow: bot.inventory, + destContainerId: ContainerIds.HOTBAR_AND_INVENTORY, + destStart: 0, + destEnd: SlotRanges.INVENTORY_END, + itemType, + metadata, + nbt, + count, + }); +} + +// ============================================================================ +// Two-Step Transfer Helper +// ============================================================================ + +/** + * Perform a two-step transfer through cursor + * Step 1: Take from source to cursor + * Step 2: Place from cursor to destination + * + * This is useful for single-item transfers where you need cursor stack ID tracking. + * + * @returns The cursor stack ID after the take operation + */ +export async function twoStepTransfer(bot: BedrockBot, source: SlotLocation, destination: SlotLocation, count: number): Promise<{ success: boolean; cursorStackId: number }> { + // Step 1: Take to cursor + const takeRequestId = getNextItemStackRequestId(); + let cursorStackId = source.stackId; + + const takeResponseHandler = (packet: protocolTypes.packet_item_stack_response) => { + for (const response of packet.responses) { + if (response.request_id === takeRequestId && response.status === 'ok') { + for (const container of response.containers || []) { + if (container.slot_type?.container_id === 'cursor' && container.slots?.length > 0) { + cursorStackId = container.slots[0].item_stack_id; + } + } + } + } + }; + bot._client.on('item_stack_response', takeResponseHandler); + + sendRequest(bot, takeRequestId, actions().takeToCursor(count, source).build()); + + let success = await waitForResponse(bot, takeRequestId); + bot._client.removeListener('item_stack_response', takeResponseHandler); + + if (!success) { + return { success: false, cursorStackId: 0 }; + } + + // Step 2: Place from cursor + const placeRequestId = getNextItemStackRequestId(); + + sendRequest(bot, placeRequestId, actions().placeFromCursor(count, cursorStackId, destination).build()); + + success = await waitForResponse(bot, placeRequestId); + + return { success, cursorStackId }; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/crafting-core.mts b/bridge/lib/mineflayer/lib/bedrock/crafting-core.mts new file mode 100644 index 0000000..57036fc --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/crafting-core.mts @@ -0,0 +1,652 @@ +/** + * Crafting Core - Recipe management and crafting utilities for Bedrock protocol + * + * Provides: + * - Recipe parsing from crafting_data packet + * - Recipe lookup by output item + * - Ingredient matching (supports item_tag, complex_alias) + * - Crafting execution with craft_recipe_auto + * + * Used by bedrockPlugins/craft.mts + */ + +import type { Item } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { BedrockBot } from '../../index.js'; +import type { protocolTypes } from '../../bedrock-types.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds } from './item-stack-actions.mts'; + +// ============================================================================ +// Types +// ============================================================================ + +type RecipeIngredient = protocolTypes.RecipeIngredient; +type ItemLegacy = protocolTypes.ItemLegacy; + +/** + * Parsed recipe structure from Bedrock crafting_data packet + */ +export interface BedrockRecipe { + type: 'shaped' | 'shapeless' | 'furnace' | 'furnace_with_metadata' | 'smithing_transform' | 'smithing_trim' | 'multi' | 'shulker_box' | 'shapeless_chemistry' | 'shaped_chemistry'; + recipeId: string; + networkId: number; + uuid?: string; + block: string; + priority?: number; + // For shaped recipes + width?: number; + height?: number; + input: RecipeIngredient[] | RecipeIngredient[][]; + output: ItemLegacy[]; + // For smithing recipes + template?: RecipeIngredient; + base?: RecipeIngredient; + addition?: RecipeIngredient; + result?: ItemLegacy; +} + +/** + * Prismarine-recipe compatible format + */ +export interface Recipe { + result: { id: number; count: number; metadata: number }; + inShape: { id: number; metadata: number | null }[][] | null; + ingredients: { id: number; metadata: number | null }[] | null; + requiresTable: boolean; + delta: { id: number; metadata: number | null; count: number }[]; + // Bedrock-specific + networkId: number; + bedrockRecipe: BedrockRecipe; +} + +// ============================================================================ +// Slot Constants +// ============================================================================ + +export const CraftingSlots = { + // 2x2 player inventory crafting grid + CRAFTING_2X2_BASE: 28, // crafting_input:28-31 for 2x2 + + // 3x3 crafting table grid + CRAFTING_3X3_BASE: 32, // crafting_input:32-40 for 3x3 + + // Creative output slot + CREATIVE_OUTPUT_SLOT: 50, +} as const; + +// ============================================================================ +// Recipe Parsing +// ============================================================================ + +/** + * Parse a recipe from the crafting_data packet + */ +export function parseRecipe(entry: protocolTypes.Recipes[number]): BedrockRecipe | null { + const type = entry.type; + const recipe = entry.recipe; + + if (!recipe) return null; + + switch (type) { + case 'shaped': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + uuid: recipe.uuid, + block: recipe.block, + priority: recipe.priority, + width: recipe.width, + height: recipe.height, + input: recipe.input, + output: recipe.output, + }; + + case 'shapeless': + case 'shulker_box': + case 'shapeless_chemistry': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + uuid: recipe.uuid, + block: recipe.block, + priority: recipe.priority, + input: recipe.input, + output: recipe.output, + }; + + case 'shaped_chemistry': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + uuid: recipe.uuid, + block: recipe.block, + priority: recipe.priority, + width: recipe.width, + height: recipe.height, + input: recipe.input, + output: recipe.output, + }; + + case 'furnace': + case 'furnace_with_metadata': + // Furnace recipes have different structure + return { + type, + recipeId: `furnace_${recipe.input_id}_${recipe.input_meta || 0}`, + networkId: 0, // Furnace recipes don't have network_id + block: recipe.block, + input: [ + { + type: 'int_id_meta', + network_id: recipe.input_id, + metadata: type === 'furnace_with_metadata' ? recipe.input_meta : 32767, + count: 1, + }, + ], + output: [recipe.output], + }; + + case 'smithing_transform': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + block: recipe.tag || 'smithing_table', + template: recipe.template, + base: recipe.base, + addition: recipe.addition, + result: recipe.result, + input: [], + output: recipe.result ? [recipe.result] : [], + }; + + case 'smithing_trim': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + block: recipe.block, + template: recipe.template, + input: [], + output: [], + }; + + case 'multi': + // Multi recipes are special (e.g., banner patterns, firework stars) + return { + type, + recipeId: `multi_${recipe.uuid}`, + networkId: recipe.network_id, + uuid: recipe.uuid, + block: 'crafting_table', + input: [], + output: [], + }; + + default: + return null; + } +} + +// ============================================================================ +// Recipe Utilities +// ============================================================================ + +/** + * Check if a recipe can be crafted in 2x2 grid (player inventory) + */ +export function fitsIn2x2(recipe: BedrockRecipe): boolean { + if (recipe.type === 'shaped' || recipe.type === 'shaped_chemistry') { + const w = recipe.width || 0; + const h = recipe.height || 0; + return w <= 2 && h <= 2; + } + if (recipe.type === 'shapeless' || recipe.type === 'shulker_box' || recipe.type === 'shapeless_chemistry') { + const ingredientCount = Array.isArray(recipe.input) ? (recipe.input as RecipeIngredient[]).length : 0; + return ingredientCount <= 4; + } + return false; +} + +/** + * Get flattened ingredients from a recipe + */ +export function getIngredients(recipe: BedrockRecipe): RecipeIngredient[] { + if (recipe.type === 'shaped' || recipe.type === 'shaped_chemistry') { + // Flatten 2D array + const input = recipe.input as RecipeIngredient[][]; + return input.flat(); + } + return recipe.input as RecipeIngredient[]; +} + +/** + * Resolve an ingredient to a network_id + * Handles different ingredient types: int_id_meta, complex_alias, item_tag + */ +export function resolveIngredientId(ing: RecipeIngredient, registry: any): number { + if (ing.type === 'invalid') return -1; + + // Direct network_id + if (ing.network_id !== undefined && ing.network_id > 0) { + return ing.network_id; + } + + // complex_alias: look up by name + if (ing.type === 'complex_alias' && (ing as any).name) { + const name = (ing as any).name as string; + // Remove "minecraft:" prefix and look up in registry + const shortName = name.replace('minecraft:', ''); + const item = registry.itemsByName[shortName]; + if (item) return item.id; + } + + // item_tag: best-effort resolution based on tag name + // Common tags: minecraft:coals → coal + if (ing.type === 'item_tag' && (ing as any).tag) { + const tag = (ing as any).tag as string; + // Try to extract item name from tag (e.g., "minecraft:coals" → "coal") + const tagName = tag.replace('minecraft:', '').replace(/s$/, ''); // Remove trailing 's' + const item = registry.itemsByName[tagName]; + if (item) return item.id; + } + + return 0; +} + +/** + * Check if an inventory item matches an ingredient + * Handles different ingredient types including item_tag + */ +export function itemMatchesIngredient(item: Item, ing: RecipeIngredient): boolean { + if (ing.type === 'invalid') return false; + + // Direct network_id match + if (ing.network_id !== undefined && ing.network_id > 0) { + if (item.type !== ing.network_id) return false; + // Check metadata if specified (32767 = wildcard) + if (ing.metadata !== undefined && ing.metadata !== 32767 && item.metadata !== ing.metadata) { + return false; + } + return true; + } + + // complex_alias: match by name + if (ing.type === 'complex_alias' && (ing as any).name) { + const name = (ing as any).name as string; + const shortName = name.replace('minecraft:', ''); + return item.name === shortName; + } + + // item_tag: check if item has the tag + // For now, use simple name matching (e.g., "minecraft:coals" matches "coal", "charcoal") + if (ing.type === 'item_tag' && (ing as any).tag) { + const tag = (ing as any).tag as string; + const tagName = tag.replace('minecraft:', ''); + // Check common tag patterns + if (tagName === 'coals') { + return item.name === 'coal' || item.name === 'charcoal'; + } + if (tagName === 'logs' || tagName === 'oak_logs') { + return item.name?.endsWith('_log') || item.name?.endsWith('_wood'); + } + if (tagName === 'planks') { + return item.name?.endsWith('_planks'); + } + // Generic: try singular form + const singular = tagName.replace(/s$/, ''); + return item.name === singular || item.name === tagName; + } + + return false; +} + +/** + * Count items in inventory that match an ingredient + */ +export function countMatchingItems(bot: BedrockBot, ing: RecipeIngredient): number { + let count = 0; + for (const item of bot.inventory.slots) { + if (!item) continue; + if (itemMatchesIngredient(item, ing)) { + count += item.count; + } + } + return count; +} + +/** + * Find inventory slots containing items that match a recipe ingredient + */ +export function findIngredientSlots(bot: BedrockBot, ingredient: RecipeIngredient): number[] { + const slots: number[] = []; + + for (let i = 0; i < bot.inventory.slots.length; i++) { + const item = bot.inventory.slots[i]; + if (!item) continue; + + if (itemMatchesIngredient(item, ingredient)) { + slots.push(i); + } + } + return slots; +} + +// ============================================================================ +// Recipe Conversion +// ============================================================================ + +/** + * Convert Bedrock recipe to prismarine-recipe compatible format + */ +export function convertToRecipe(bedrock: BedrockRecipe, registry: any): Recipe { + const output = bedrock.output[0] || { network_id: 0, count: 1, metadata: 0 }; + + // Determine if recipe requires crafting table + const requiresTable = !fitsIn2x2(bedrock) || (bedrock.block !== 'deprecated' && !fitsIn2x2(bedrock)); + + // Build inShape for shaped recipes + let inShape: { id: number; metadata: number | null }[][] | null = null; + let ingredients: { id: number; metadata: number | null }[] | null = null; + + if (bedrock.type === 'shaped' || bedrock.type === 'shaped_chemistry') { + const input = bedrock.input as RecipeIngredient[][]; + inShape = input.map((row) => + row.map((ing) => ({ + id: ing.type === 'invalid' ? -1 : ing.network_id || 0, + metadata: ing.metadata === 32767 ? null : ing.metadata || null, + })) + ); + } else if (bedrock.type === 'shapeless' || bedrock.type === 'shulker_box' || bedrock.type === 'shapeless_chemistry') { + const input = bedrock.input as RecipeIngredient[]; + ingredients = input.map((ing) => ({ + id: ing.network_id || 0, + metadata: ing.metadata === 32767 ? null : ing.metadata || null, + })); + } + + // Compute delta (inventory change) + const delta: { id: number; metadata: number | null; count: number }[] = []; + + // Add consumed ingredients (negative) + for (const ing of getIngredients(bedrock)) { + if (ing.type === 'invalid') continue; + const id = resolveIngredientId(ing, registry); + const metadata = ing.metadata === 32767 ? null : ing.metadata || null; + const existing = delta.find((d) => d.id === id && d.metadata === metadata); + if (existing) { + existing.count -= ing.count; + } else { + delta.push({ id, metadata, count: -ing.count }); + } + } + + // Add produced output (positive) + for (const out of bedrock.output) { + const id = out.network_id; + const metadata = out.metadata || null; + const existing = delta.find((d) => d.id === id && d.metadata === metadata); + if (existing) { + existing.count += out.count; + } else { + delta.push({ id, metadata, count: out.count }); + } + } + + return { + result: { + id: output.network_id, + count: output.count, + metadata: output.metadata || 0, + }, + inShape, + ingredients, + requiresTable, + delta, + networkId: bedrock.networkId, + bedrockRecipe: bedrock, + }; +} + +// ============================================================================ +// Recipe Lookup +// ============================================================================ + +/** + * Find all recipes that produce a given item + */ +export function findRecipesByOutput(recipesByOutputId: Map, registry: any, itemType: number, metadata: number | null): Recipe[] { + const bedrock = recipesByOutputId.get(itemType) || []; + return bedrock + .filter((r) => { + // Filter by metadata if specified + if (metadata !== null) { + const output = r.output[0]; + if (output && output.metadata !== metadata && output.metadata !== 32767) { + return false; + } + } + return true; + }) + .map((r) => convertToRecipe(r, registry)); +} + +/** + * Check if player has enough items for a recipe + */ +export function hasIngredientsFor(bot: BedrockBot, recipe: Recipe, count: number = 1): boolean { + const bedrock = recipe.bedrockRecipe; + const ingredients = getIngredients(bedrock); + + // Track how many of each ingredient slot we need + const needed = new Map(); + + for (const ing of ingredients) { + if (ing.type === 'invalid') continue; + + // Create a key for this ingredient type + const key = JSON.stringify({ type: ing.type, network_id: ing.network_id, tag: (ing as any).tag, name: (ing as any).name }); + const existing = needed.get(key); + if (existing) { + existing.count += ing.count * count; + } else { + needed.set(key, { ing, count: ing.count * count }); + } + } + + // Check if we have enough of each ingredient + for (const [, { ing, count: neededCount }] of needed) { + const available = countMatchingItems(bot, ing); + if (available < neededCount) { + return false; + } + } + + return true; +} + +// ============================================================================ +// Crafting Execution +// ============================================================================ + +/** + * Craft using craft_recipe_auto action + * This tells the server to automatically source ingredients. + * + * Based on real client packet captures, the format is: + * 1. craft_recipe_auto with recipe_network_id, times_crafted, times_crafted_2, ingredients + * 2. results_deprecated with result_items and times_crafted + * 3. consume actions from hotbar_and_inventory for each ingredient slot + * 4. place action from creative_output:50 to hotbar_and_inventory + */ +export async function craftWithAuto( + bot: BedrockBot, + bedrock: BedrockRecipe, + Item: any // Item constructor from prismarine-item +): Promise { + const requestId = getNextItemStackRequestId(); + const outputCount = bedrock.output[0]?.count ?? 1; + const ingredients = getIngredients(bedrock).filter((ing) => ing.type !== 'invalid'); + + // Build result_items for results_deprecated + const resultItems = bedrock.output.map((output) => ({ + network_id: output.network_id, + count: output.count, + metadata: output.metadata ?? 0, + block_runtime_id: output.block_runtime_id ?? 0, + extra: output.extra ?? { has_nbt: 0, can_place_on: [], can_destroy: [] }, + })); + + // Find inventory slots with ingredients and build consume actions + const consumeActions: any[] = []; + const ingredientCounts = new Map(); + const resolvedIngredients: RecipeIngredient[] = []; + + for (const ing of ingredients) { + if (ing.type === 'invalid') continue; + + // Find a slot with this ingredient using itemMatchesIngredient + for (let slot = 0; slot < bot.inventory.slots.length; slot++) { + const item = bot.inventory.slots[slot]; + if (!item) continue; + if (!itemMatchesIngredient(item, ing)) continue; + + const key = slot; + const existing = ingredientCounts.get(key); + if (existing) { + existing.count += ing.count; + } else { + ingredientCounts.set(key, { + slot, + stackId: getStackId(item), + count: ing.count, + }); + } + + // Resolve item_tag/complex_alias to int_id_meta using the actual item + if (ing.type === 'item_tag' || ing.type === 'complex_alias') { + resolvedIngredients.push({ + type: 'int_id_meta', + network_id: item.type, + metadata: item.metadata ?? 32767, + count: ing.count, + } as RecipeIngredient); + } else { + resolvedIngredients.push(ing); + } + + break; + } + } + + // Build consume actions + let firstConsume = true; + for (const [, info] of ingredientCounts) { + consumeActions.push({ + type_id: 'consume', + count: info.count, + source: { + slot_type: { container_id: ContainerIds.HOTBAR_AND_INVENTORY }, + slot: info.slot, + stack_id: firstConsume ? info.stackId : requestId, + }, + }); + firstConsume = false; + } + + // Find an empty slot for the output + let outputSlot = -1; + for (let slot = 0; slot < bot.inventory.slots.length; slot++) { + if (!bot.inventory.slots[slot]) { + outputSlot = slot; + break; + } + } + if (outputSlot === -1) { + throw new Error('No empty inventory slot for crafting output'); + } + + const actionList: any[] = [ + { + type_id: 'craft_recipe_auto', + recipe_network_id: bedrock.networkId, + times_crafted_2: 1, + times_crafted: 1, + ingredients: resolvedIngredients, + }, + { + type_id: 'results_deprecated', + result_items: resultItems, + times_crafted: 1, + }, + ...consumeActions, + { + type_id: 'place', + count: outputCount, + source: { + slot_type: { container_id: ContainerIds.CREATIVE_OUTPUT }, + slot: CraftingSlots.CREATIVE_OUTPUT_SLOT, + stack_id: requestId, + }, + destination: { + slot_type: { container_id: ContainerIds.HOTBAR_AND_INVENTORY }, + slot: outputSlot, + stack_id: 0, + }, + }, + ]; + + bot.logger.info(`Sending craft_recipe_auto: id=${requestId}, recipe=${bedrock.networkId}`); + bot.logger.debug(` original ingredients: ${JSON.stringify(ingredients)}`); + bot.logger.debug(` resolved ingredients: ${JSON.stringify(resolvedIngredients)}`); + bot.logger.debug(` actions: ${JSON.stringify(actionList)}`); + + sendRequest(bot, requestId, actionList); + + const success = await waitForResponse(bot, requestId); + if (!success) { + throw new Error('Crafting failed - server rejected craft_recipe_auto request'); + } + + // Create the output item in the destination slot + const output = bedrock.output[0]; + if (output) { + const newItem = new Item(output.network_id, outputCount, output.metadata ?? 0); + (newItem as any).stackId = requestId; + bot.inventory.updateSlot(outputSlot, newItem); + bot.logger.debug(`Created crafted item in slot ${outputSlot}: ${output.network_id} x${outputCount}`); + } +} + +/** + * Find an item in all inventory slots by type or name + */ +export function findItemInAllSlots(bot: BedrockBot, itemTypeOrName: number | string, metadata: number | null): Item | null { + for (let i = 0; i < bot.inventory.slots.length; i++) { + const item = bot.inventory.slots[i]; + if (!item) continue; + + const matches = typeof itemTypeOrName === 'number' ? item.type === itemTypeOrName : item.name === itemTypeOrName; + + if (matches && (metadata === null || item.metadata === metadata)) { + return item; + } + } + return null; +} + +/** + * Count items across all inventory slots + */ +export function countAllItems(bot: BedrockBot, itemType: number, metadata: number | null): number { + let sum = 0; + for (const item of bot.inventory.slots) { + if (item && item.type === itemType && (metadata === null || metadata === 32767 || item.metadata === metadata)) { + sum += item.count; + } + } + return sum; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/index.mts b/bridge/lib/mineflayer/lib/bedrock/index.mts new file mode 100644 index 0000000..e4ea4b0 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/index.mts @@ -0,0 +1,157 @@ +/** + * Bedrock Implementation Core + * + * This module provides reusable utilities for Bedrock-specific implementations: + * - Action builders for item_stack_request protocol + * - Slot mapping between Bedrock and prismarine-windows + * - Container transfer operations + * + * These utilities are used by the bedrockPlugins but can also be used + * by external plugins that need to interact with Bedrock inventory. + */ + +// Item Stack Actions - Core action building utilities +export { + // Stack ID helpers + getStackId, + setStackId, + + // Container ID constants + ContainerIds, + type ContainerId, + + // Slot location types and helpers + type SlotLocation, + cursor, + slot, + containerSlot, + inventorySlot, + fromPlayerSlot, + fromItem, + + // Action builder + ActionBuilder, + actions, + + // Request ID management + getNextItemStackRequestId, + getNextLegacyRequestId, + resetRequestIds, + + // Request execution + type ItemStackResult, + executeRequest, + sendRequest, + waitForResponse, + captureCursorStackId, + + // Convenience builders + buildTakeRequest, + buildPlaceRequest, + buildSwapRequest, + buildDropRequest, +} from './item-stack-actions.mts'; + +// Slot Mapping - Container ID and slot index mapping +export { + SlotRanges, + getSlotIndex, + getWindow, + type ContainerLocation, + getContainerForCursorOp, + getContainerFromSlot, + slotToLocation, + isHotbarSlot, + isInventorySlot, + isArmorSlot, + isOffhandSlot, + getArmorType, + getArmorSlot, +} from './slot-mapping.mts'; + +// Container Operations - Transfer utilities +export { type TransferConfig, transferItems, depositToContainer, withdrawFromContainer, twoStepTransfer } from './container.mts'; + +// Crafting Core - Recipe management and crafting +export { + // Types + type BedrockRecipe, + type Recipe, + + // Slot constants + CraftingSlots, + + // Recipe parsing + parseRecipe, + + // Recipe utilities + fitsIn2x2, + getIngredients, + resolveIngredientId, + itemMatchesIngredient, + countMatchingItems, + findIngredientSlots, + + // Recipe conversion and lookup + convertToRecipe, + findRecipesByOutput, + hasIngredientsFor, + + // Crafting execution + craftWithAuto, + + // Item helpers + findItemInAllSlots, + countAllItems, +} from './crafting-core.mts'; + +// Workstations - Specialized container interfaces +export { + // Furnace + openFurnace, + FurnaceSlots, + type Furnace, + + // Anvil + openAnvil, + AnvilSlots, + type Anvil, + + // Enchanting + openEnchantmentTable, + EnchantingSlots, + type EnchantmentTable, + + // Smithing + openSmithingTable, + SmithingSlots, + type SmithingTable, + + // Stonecutter + openStonecutter, + StonecutterSlots, + type Stonecutter, + + // Grindstone + openGrindstone, + GrindstoneSlots, + type Grindstone, + + // Loom + openLoom, + LoomSlots, + type Loom, + + // Brewing Stand + openBrewingStand, + BrewingSlots, + type BrewingStand, + + // Cartography Table + openCartographyTable, + CartographySlots, + type CartographyTable, +} from './workstations/index.mts'; + +// Window Types - Bedrock slot mappings are now handled by prismarine-windows patch +// See patches/prismarine-windows+2.9.0.patch for the Bedrock window definitions diff --git a/bridge/lib/mineflayer/lib/bedrock/item-stack-actions.mts b/bridge/lib/mineflayer/lib/bedrock/item-stack-actions.mts new file mode 100644 index 0000000..6d4a689 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/item-stack-actions.mts @@ -0,0 +1,624 @@ +/** + * Item Stack Actions - Core action building utilities for Bedrock item_stack_request protocol + * + * This module provides: + * - Stack ID helpers (replaces 50+ occurrences of `(item as any).stackId ?? 0`) + * - Container ID constants + * - SlotLocation type and helpers + * - ActionBuilder class with fluent API + * - Request ID management + * - Request execution and response waiting + */ + +import type { Item } from 'prismarine-item'; +import type { BedrockBot } from '../../index.js'; +import type { protocolTypes } from '../../bedrock-types.js'; + +// ============================================================================ +// Stack ID Helpers +// ============================================================================ + +/** + * Get stack ID from an item safely. + * Replaces pattern: `(item as any).stackId ?? 0` + */ +export function getStackId(item: Item | null | undefined): number { + return (item as any)?.stackId ?? 0; +} + +/** + * Set stack ID on an item. + * Replaces pattern: `(item as any).stackId = value` + */ +export function setStackId(item: Item, stackId: number): void { + (item as any).stackId = stackId; +} + +// ============================================================================ +// Container ID Constants +// ============================================================================ + +/** + * Container IDs used in Bedrock item_stack_request protocol + */ +export const ContainerIds = { + // Cursor + CURSOR: 'cursor', + + // Player inventory sections + HOTBAR: 'hotbar', + INVENTORY: 'inventory', + HOTBAR_AND_INVENTORY: 'hotbar_and_inventory', + ARMOR: 'armor', + OFFHAND: 'offhand', + + // External containers + CONTAINER: 'container', + + // Crafting + CRAFTING_INPUT: 'crafting_input', + CREATIVE_OUTPUT: 'creative_output', + CREATED_OUTPUT: 'created_output', + + // Furnace + FURNACE_INGREDIENT: 'furnace_ingredient', + FURNACE_FUEL: 'furnace_fuel', + FURNACE_OUTPUT: 'furnace_output', + + // Enchanting + ENCHANTING_INPUT: 'enchanting_input', + ENCHANTING_LAPIS: 'enchanting_lapis', + + // Anvil + ANVIL_INPUT: 'anvil_input', + ANVIL_MATERIAL: 'anvil_material', + + // Stonecutter + STONECUTTER_INPUT: 'stonecutter_input', + + // Smithing Table + SMITHING_TABLE_TEMPLATE: 'smithing_table_template', + SMITHING_TABLE_INPUT: 'smithing_table_input', + SMITHING_TABLE_MATERIAL: 'smithing_table_material', + + // Brewing Stand + BREWING_INPUT: 'brewing_input', + BREWING_FUEL: 'brewing_fuel', + BREWING_RESULT: 'brewing_result', + + // Grindstone + GRINDSTONE_INPUT: 'grindstone_input', + + // Loom + LOOM_INPUT: 'loom_input', + LOOM_DYE: 'loom_dye', + + // Cartography Table + CARTOGRAPHY_INPUT: 'cartography_input', + CARTOGRAPHY_ADDITIONAL: 'cartography_additional', +} as const; + +export type ContainerId = (typeof ContainerIds)[keyof typeof ContainerIds]; + +// ============================================================================ +// Slot Location Type and Helpers +// ============================================================================ + +/** + * Represents a slot location for item_stack_request actions + */ +export interface SlotLocation { + containerId: string; + slot: number; + stackId: number; + dynamicContainerId?: number; +} + +/** + * Create a cursor slot location + */ +export function cursor(stackId: number = 0): SlotLocation { + return { containerId: ContainerIds.CURSOR, slot: 0, stackId }; +} + +/** + * Create a slot location from container ID and slot index + */ +export function slot(containerId: string, slotIndex: number, stackId: number = 0, dynamicContainerId?: number): SlotLocation { + return { containerId, slot: slotIndex, stackId, dynamicContainerId }; +} + +/** + * Create a container slot location (for chests, etc.) + */ +export function containerSlot(slotIndex: number, stackId: number = 0): SlotLocation { + return { containerId: ContainerIds.CONTAINER, slot: slotIndex, stackId }; +} + +/** + * Create a hotbar_and_inventory slot location + */ +export function inventorySlot(slotIndex: number, stackId: number = 0): SlotLocation { + return { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }; +} + +/** + * Create a SlotLocation from a player inventory slot index + * Maps to correct container based on slot range: + * - 0-8: hotbar + * - 9-35: inventory + * - 36-39: armor + * - 45: offhand + */ +export function fromPlayerSlot(slotIndex: number, item?: Item | null): SlotLocation { + const stackId = getStackId(item); + + if (slotIndex >= 0 && slotIndex <= 8) { + return { containerId: ContainerIds.HOTBAR, slot: slotIndex, stackId }; + } else if (slotIndex >= 9 && slotIndex <= 35) { + return { containerId: ContainerIds.INVENTORY, slot: slotIndex, stackId }; + } else if (slotIndex >= 36 && slotIndex <= 39) { + return { containerId: ContainerIds.ARMOR, slot: slotIndex - 36, stackId }; + } else if (slotIndex === 45) { + return { containerId: ContainerIds.OFFHAND, slot: 1, stackId }; + } else { + throw new Error(`Invalid player inventory slot index: ${slotIndex}`); + } +} + +/** + * Create a SlotLocation from an item (uses item.slot) + */ +export function fromItem(containerId: string, item: Item): SlotLocation { + return { + containerId, + slot: item.slot, + stackId: getStackId(item), + }; +} + +// ============================================================================ +// Action Types +// ============================================================================ + +type ItemStackAction = protocolTypes.ItemStackRequest['actions'][number]; +type StackRequestSlotInfo = protocolTypes.StackRequestSlotInfo; +type RecipeIngredient = protocolTypes.RecipeIngredient; +type ItemLegacy = protocolTypes.ItemLegacy; + +/** + * Convert SlotLocation to StackRequestSlotInfo format + */ +function toSlotInfo(loc: SlotLocation): StackRequestSlotInfo { + const slotType: { container_id: string; dynamic_container_id?: number } = { + container_id: loc.containerId, + }; + if (loc.dynamicContainerId !== undefined) { + slotType.dynamic_container_id = loc.dynamicContainerId; + } + return { + slot_type: slotType as any, + slot: loc.slot, + stack_id: loc.stackId, + }; +} + +// ============================================================================ +// Action Builder Class +// ============================================================================ + +/** + * Fluent builder for item_stack_request actions + * + * @example + * ```ts + * const actionList = actions() + * .takeToCursor(count, source) + * .placeFromCursor(count, cursorStackId, dest) + * .build(); + * ``` + */ +export class ActionBuilder { + private actionList: ItemStackAction[] = []; + + /** + * Take items from source to destination + */ + take(count: number, source: SlotLocation, destination: SlotLocation): this { + this.actionList.push({ + type_id: 'take', + count, + source: toSlotInfo(source), + destination: toSlotInfo(destination), + } as any); + return this; + } + + /** + * Take items from source to cursor + */ + takeToCursor(count: number, source: SlotLocation, cursorStackId: number = 0): this { + return this.take(count, source, cursor(cursorStackId)); + } + + /** + * Place items from source to destination + */ + place(count: number, source: SlotLocation, destination: SlotLocation): this { + this.actionList.push({ + type_id: 'place', + count, + source: toSlotInfo(source), + destination: toSlotInfo(destination), + } as any); + return this; + } + + /** + * Place items from cursor to destination + */ + placeFromCursor(count: number, cursorStackId: number, destination: SlotLocation): this { + return this.place(count, cursor(cursorStackId), destination); + } + + /** + * Swap items between source and destination + */ + swap(source: SlotLocation, destination: SlotLocation): this { + this.actionList.push({ + type_id: 'swap', + source: toSlotInfo(source), + destination: toSlotInfo(destination), + } as any); + return this; + } + + /** + * Drop items from source + */ + drop(count: number, source: SlotLocation, randomly: boolean = false): this { + this.actionList.push({ + type_id: 'drop', + count, + source: toSlotInfo(source), + randomly, + } as any); + return this; + } + + /** + * Consume items from source (used in crafting) + */ + consume(count: number, source: SlotLocation): this { + this.actionList.push({ + type_id: 'consume', + count, + source: toSlotInfo(source), + } as any); + return this; + } + + /** + * Destroy items from source (creative mode) + */ + destroy(count: number, source: SlotLocation): this { + this.actionList.push({ + type_id: 'destroy', + count, + source: toSlotInfo(source), + } as any); + return this; + } + + /** + * Create item (creative mode) + */ + create(resultSlotId: number = 0): this { + this.actionList.push({ + type_id: 'create', + result_slot_id: resultSlotId, + } as any); + return this; + } + + /** + * Craft creative action (pick item from creative inventory) + * Used with results_deprecated and take actions + * @param itemId - The entry_id from creative_content packet + * @param timesCrafted - How many times to craft (default 1) + */ + craftCreative(itemId: number, timesCrafted: number = 1): this { + this.actionList.push({ + type_id: 'craft_creative', + item_id: itemId, + times_crafted: timesCrafted, + } as any); + return this; + } + + /** + * Craft recipe action + */ + craftRecipe(recipeNetworkId: number, timesCrafted: number = 1): this { + this.actionList.push({ + type_id: 'craft_recipe', + recipe_network_id: recipeNetworkId, + times_crafted: timesCrafted, + } as any); + return this; + } + + /** + * Craft recipe auto action (shift-click crafting / auto ingredient sourcing) + */ + craftRecipeAuto(recipeNetworkId: number, timesCrafted: number = 1, ingredients?: RecipeIngredient[]): this { + this.actionList.push({ + type_id: 'craft_recipe_auto', + recipe_network_id: recipeNetworkId, + times_crafted: timesCrafted, + times_crafted_2: timesCrafted, + ingredients, + } as any); + return this; + } + + /** + * Results deprecated action (required for crafting to declare expected outputs) + */ + resultsDeprecated(resultItems: ItemLegacy[], timesCrafted: number = 1): this { + this.actionList.push({ + type_id: 'results_deprecated', + result_items: resultItems, + times_crafted: timesCrafted, + } as any); + return this; + } + + /** + * Optional action (anvil renaming, cartography) + */ + optional(filteredStringIndex: number = 0): this { + this.actionList.push({ + type_id: 'optional', + filtered_string_index: filteredStringIndex, + } as any); + return this; + } + + /** + * Craft loom request action (banner patterns) + * Note: Loom does NOT use recipeNetworkId + */ + craftLoomRequest(timesCrafted: number = 1): this { + this.actionList.push({ + type_id: 'craft_loom_request', + times_crafted: timesCrafted, + } as any); + return this; + } + + /** + * Craft grindstone request action (disenchanting) + */ + craftGrindstoneRequest(recipeNetworkId: number, timesCrafted: number = 1): this { + this.actionList.push({ + type_id: 'craft_grindstone_request', + recipe_network_id: recipeNetworkId, + times_crafted: timesCrafted, + } as any); + return this; + } + + /** + * Mine block action (tool durability) + */ + mineBlock(hotbarSlot: number, predictedDurability: number, networkId: number): this { + this.actionList.push({ + type_id: 'mine_block', + hotbar_slot: hotbarSlot, + predicted_durability: predictedDurability, + network_id: networkId, + } as any); + return this; + } + + /** + * Get the built actions array + */ + build(): ItemStackAction[] { + return this.actionList; + } + + /** + * Reset the builder for reuse + */ + reset(): this { + this.actionList = []; + return this; + } + + /** + * Get current action count + */ + get length(): number { + return this.actionList.length; + } +} + +/** + * Create a new action builder + */ +export function actions(): ActionBuilder { + return new ActionBuilder(); +} + +// ============================================================================ +// Request ID Management +// ============================================================================ + +let nextRequestId = -861; +let nextLegacyRequestId = -1; + +/** + * Get next item_stack_request ID (shared across all plugins) + * Uses negative IDs decremented by 2 + */ +export function getNextItemStackRequestId(): number { + nextRequestId -= 2; + return nextRequestId + 2; +} + +/** + * Get next legacy_request_id for inventory_transaction packets + */ +export function getNextLegacyRequestId(): number { + return nextLegacyRequestId--; +} + +/** + * Reset request IDs (useful for testing) + */ +export function resetRequestIds(): void { + nextRequestId = -861; + nextLegacyRequestId = -1; +} + +// ============================================================================ +// Request Execution +// ============================================================================ + +export interface ItemStackResult { + success: boolean; + requestId: number; +} + +/** + * Send an item_stack_request and await response + */ +export async function executeRequest(bot: BedrockBot, actionList: ItemStackAction[], customNames: string[] = [], cause: number = -1, timeout: number = 5000): Promise { + const requestId = getNextItemStackRequestId(); + + bot._client.write('item_stack_request', { + requests: [ + { + request_id: requestId, + actions: actionList, + custom_names: customNames, + cause, + }, + ], + }); + + const success = await waitForResponse(bot, requestId, timeout); + return { success, requestId }; +} + +/** + * Send an item_stack_request with a specific request ID + */ +export function sendRequest(bot: BedrockBot, requestId: number, actionList: ItemStackAction[], customNames: string[] = [], cause: number = -1): void { + bot._client.write('item_stack_request', { + requests: [ + { + request_id: requestId, + actions: actionList, + custom_names: customNames, + cause, + }, + ], + }); +} + +/** + * Wait for item_stack_response with given request ID + */ +export function waitForResponse(bot: BedrockBot, requestId: number, timeout: number = 5000): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + bot.removeListener(`itemStackResponse:${requestId}`, handler); + resolve(false); + }, timeout); + + const handler = async (success: boolean) => { + clearTimeout(timer); + // Small delay to allow inventory updates to process + await new Promise((r) => setTimeout(r, 100)); + resolve(success); + }; + + bot.once(`itemStackResponse:${requestId}`, handler); + }); +} + +/** + * Capture cursor stack ID from item_stack_response + * Used for chained requests where cursor stack ID changes + */ +export function captureCursorStackId(bot: BedrockBot, requestId: number, callback: (stackId: number) => void): () => void { + const handler = (packet: protocolTypes.packet_item_stack_response) => { + for (const response of packet.responses) { + if (response.request_id === requestId && response.status === 'ok') { + for (const container of response.containers || []) { + if (container.slot_type?.container_id === 'cursor' && container.slots?.length > 0) { + callback(container.slots[0].item_stack_id); + } + } + } + } + }; + + bot._client.on('item_stack_response', handler); + + // Return cleanup function + return () => { + bot._client.removeListener('item_stack_response', handler); + }; +} + +// ============================================================================ +// Convenience Functions for Common Patterns +// ============================================================================ + +/** + * Build a complete take-to-cursor request + */ +export function buildTakeRequest(source: SlotLocation, count: number, cursorStackId: number = 0): { actions: ItemStackAction[]; customNames: string[]; cause: number } { + return { + actions: actions().takeToCursor(count, source, cursorStackId).build(), + customNames: [], + cause: -1, + }; +} + +/** + * Build a complete place-from-cursor request + */ +export function buildPlaceRequest(destination: SlotLocation, count: number, cursorStackId: number): { actions: ItemStackAction[]; customNames: string[]; cause: number } { + return { + actions: actions().placeFromCursor(count, cursorStackId, destination).build(), + customNames: [], + cause: -1, + }; +} + +/** + * Build a swap request + */ +export function buildSwapRequest(source: SlotLocation, destination: SlotLocation): { actions: ItemStackAction[]; customNames: string[]; cause: number } { + return { + actions: actions().swap(source, destination).build(), + customNames: [], + cause: -1, + }; +} + +/** + * Build a drop request + */ +export function buildDropRequest(source: SlotLocation, count: number, randomly: boolean = false): { actions: ItemStackAction[]; customNames: string[]; cause: number } { + return { + actions: actions().drop(count, source, randomly).build(), + customNames: [], + cause: -1, + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/slot-mapping.mts b/bridge/lib/mineflayer/lib/bedrock/slot-mapping.mts new file mode 100644 index 0000000..6b3ffe5 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/slot-mapping.mts @@ -0,0 +1,235 @@ +/** + * Slot Mapping - Container ID and slot index mapping utilities for Bedrock protocol + * + * Handles the translation between: + * - Bedrock window_id strings ("inventory", "armor", "hotbar", etc.) + * - Prismarine-windows slot indices (0-45 for player inventory) + * - Bedrock container IDs for item_stack_request actions + */ + +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../index.js'; +import { ContainerIds, type SlotLocation } from './item-stack-actions.mts'; + +// ============================================================================ +// Slot Index Constants +// ============================================================================ + +export const SlotRanges = { + // Bedrock slot layout: 0-8 hotbar, 9-35 inventory, 36-39 armor, 45 offhand + HOTBAR_START: 0, + HOTBAR_END: 8, + INVENTORY_START: 9, + INVENTORY_END: 35, + ARMOR_START: 36, + ARMOR_END: 39, + OFFHAND: 45, + + // Hotbar count + HOTBAR_COUNT: 9, + + // Total slots in player inventory + TOTAL_SLOTS: 46, +} as const; + +// ============================================================================ +// Window ID to Slot Index Mapping +// ============================================================================ + +/** + * Map Bedrock window_id and slot to prismarine-windows slot index + * + * @param windowId - Bedrock window ID ("inventory", "armor", "hotbar", etc.) + * @param slot - Slot index within that window + * @returns Prismarine-windows slot index + */ +export function getSlotIndex(windowId: protocolTypes.WindowID, slot: number): number { + switch (windowId) { + case 'inventory': + return slot; + case 'armor': + return SlotRanges.ARMOR_START + slot; // armor slots 36-39 (head, torso, legs, feet) + case 'offhand': + return SlotRanges.OFFHAND + slot; // offhand at slot 45 (Java compatibility) + case 'hotbar': + return slot; + default: + return slot; + } +} + +// ============================================================================ +// Window ID to Window Object Mapping +// ============================================================================ + +/** + * Map Bedrock window_id to Window object + * + * @param bot - The bot instance + * @param windowId - Bedrock window ID + * @returns Window object or null for UI windows + */ +export function getWindow(bot: BedrockBot, windowId: protocolTypes.WindowID): Window | null { + if (windowId === 'inventory' || windowId === 'armor' || windowId === 'offhand' || windowId === 'hotbar' || windowId === 'fixed_inventory') { + return bot.inventory; + } else if (windowId === 'ui') { + return null; + } else { + // For container windows (chest, furnace, etc.), use currentWindow + // Returns null if no container is currently open + return bot.currentWindow; + } +} + +// ============================================================================ +// Slot Index to Container ID Mapping +// ============================================================================ + +/** + * Container location with ID and slot + */ +export interface ContainerLocation { + containerId: string; + slot: number; +} + +/** + * Get container ID for cursor-based operations (take/place through cursor). + * Based on packet captures: + * - Hotbar slots (0-8) use "hotbar" container + * - Main inventory slots (9-35) use "inventory" container + * - Armor slots (36-39) use "armor" container + * - Offhand (45) uses "offhand" container + * + * @param slotIndex - Prismarine-windows slot index + * @returns Container location with ID and slot + */ +export function getContainerForCursorOp(slotIndex: number): ContainerLocation { + if (slotIndex >= SlotRanges.HOTBAR_START && slotIndex <= SlotRanges.HOTBAR_END) { + return { containerId: ContainerIds.HOTBAR, slot: slotIndex }; + } else if (slotIndex >= SlotRanges.INVENTORY_START && slotIndex <= SlotRanges.INVENTORY_END) { + return { containerId: ContainerIds.INVENTORY, slot: slotIndex }; + } else if (slotIndex >= SlotRanges.ARMOR_START && slotIndex <= SlotRanges.ARMOR_END) { + return { containerId: ContainerIds.ARMOR, slot: slotIndex - SlotRanges.ARMOR_START }; + } else if (slotIndex === SlotRanges.OFFHAND) { + return { containerId: ContainerIds.OFFHAND, slot: 1 }; + } else { + throw new Error(`Invalid slot index for cursor op: ${slotIndex}`); + } +} + +/** + * Get container ID from slot index, considering open container windows. + * + * @param slotIndex - Prismarine-windows slot index + * @param window - Optional window object (for container slot detection) + * @returns Container location with ID and slot + */ +export function getContainerFromSlot(slotIndex: number, window?: Window): ContainerLocation { + // If we have a container window open, check if slot is in container section + // Only apply container logic if inventoryStart > 9 (player inventory has inventoryStart=9) + // Container windows like chests have inventoryStart >= 27 + if (window && (window as any).inventoryStart !== undefined) { + const inventoryStart = (window as any).inventoryStart as number; + + // Player inventory has inventoryStart=9 (separating hotbar from main inventory) + // Container windows have inventoryStart >= 27 (chest) or more + // Only treat as container window if inventoryStart is large enough to indicate a container + if (inventoryStart > SlotRanges.INVENTORY_END) { + if (slotIndex < inventoryStart) { + // Container slot (e.g., chest slots 0-26) + return { containerId: ContainerIds.CONTAINER, slot: slotIndex }; + } + + // Adjust slot index for player inventory section within container window + // Window slots 27-62 map to player inventory + const playerSlot = slotIndex - inventoryStart; + if (playerSlot >= SlotRanges.HOTBAR_START && playerSlot <= SlotRanges.HOTBAR_END) { + return { containerId: ContainerIds.HOTBAR, slot: playerSlot }; + } else if (playerSlot >= SlotRanges.INVENTORY_START && playerSlot <= SlotRanges.INVENTORY_END) { + return { containerId: ContainerIds.INVENTORY, slot: playerSlot }; + } + } + } + + // Player inventory layout (Java compatible): + // 0-8: hotbar (hotbar slots 0-8) + // 9-35: main inventory (inventory slots 9-35) + // 36-39: armor (armor slots 0-3: head, torso, legs, feet) + // 45: offhand (offhand slot 0) + + if (slotIndex >= SlotRanges.HOTBAR_START && slotIndex <= SlotRanges.HOTBAR_END) { + return { containerId: ContainerIds.HOTBAR, slot: slotIndex }; + } else if (slotIndex >= SlotRanges.INVENTORY_START && slotIndex <= SlotRanges.INVENTORY_END) { + return { containerId: ContainerIds.INVENTORY, slot: slotIndex }; + } else if (slotIndex >= SlotRanges.ARMOR_START && slotIndex <= SlotRanges.ARMOR_END) { + return { containerId: ContainerIds.ARMOR, slot: slotIndex - SlotRanges.ARMOR_START }; + } else if (slotIndex === SlotRanges.OFFHAND) { + // Offhand uses slot 1 in item_stack_request, not 0 + return { containerId: ContainerIds.OFFHAND, slot: 1 }; + } else { + throw new Error(`Invalid slot index: ${slotIndex}`); + } +} + +/** + * Convert slot index to SlotLocation with stack ID from item + */ +export function slotToLocation(slotIndex: number, stackId: number, window?: Window): SlotLocation { + const container = getContainerFromSlot(slotIndex, window); + return { + containerId: container.containerId, + slot: container.slot, + stackId, + }; +} + +// ============================================================================ +// Inventory Section Helpers +// ============================================================================ + +/** + * Check if slot is in hotbar + */ +export function isHotbarSlot(slotIndex: number): boolean { + return slotIndex >= SlotRanges.HOTBAR_START && slotIndex <= SlotRanges.HOTBAR_END; +} + +/** + * Check if slot is in main inventory + */ +export function isInventorySlot(slotIndex: number): boolean { + return slotIndex >= SlotRanges.INVENTORY_START && slotIndex <= SlotRanges.INVENTORY_END; +} + +/** + * Check if slot is armor + */ +export function isArmorSlot(slotIndex: number): boolean { + return slotIndex >= SlotRanges.ARMOR_START && slotIndex <= SlotRanges.ARMOR_END; +} + +/** + * Check if slot is offhand + */ +export function isOffhandSlot(slotIndex: number): boolean { + return slotIndex === SlotRanges.OFFHAND; +} + +/** + * Get armor slot type (head, torso, legs, feet) + */ +export function getArmorType(slotIndex: number): 'head' | 'torso' | 'legs' | 'feet' | null { + if (!isArmorSlot(slotIndex)) return null; + const armorSlot = slotIndex - SlotRanges.ARMOR_START; + const types: ('head' | 'torso' | 'legs' | 'feet')[] = ['head', 'torso', 'legs', 'feet']; + return types[armorSlot]; +} + +/** + * Get slot index for armor type + */ +export function getArmorSlot(type: 'head' | 'torso' | 'legs' | 'feet'): number { + const offsets = { head: 0, torso: 1, legs: 2, feet: 3 }; + return SlotRanges.ARMOR_START + offsets[type]; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/anvil.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/anvil.mts new file mode 100644 index 0000000..efc8730 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/anvil.mts @@ -0,0 +1,315 @@ +/** + * Anvil Workstation - Repair and rename operations for Bedrock protocol + * + * Container IDs from packet captures: + * - anvil_input: slot 1 + * - anvil_material: slot 2 + * + * Uses 'optional' action type for rename operations + */ + +import type { Item } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../../index.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds, cursor } from '../item-stack-actions.mts'; +import { findItemInAllSlots, CraftingSlots } from '../crafting-core.mts'; +import { twoStepTransfer } from '../container.mts'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const AnvilSlots = { + INPUT: 1, + MATERIAL: 2, +} as const; + +// ============================================================================ +// Anvil Interface +// ============================================================================ + +export interface Anvil { + window: Window; + /** Rename item */ + rename: (newName: string) => Promise; + /** Combine items (repair) */ + combine: () => Promise; + /** Put item in first slot */ + putTarget: (itemType: number | string, metadata: number | null) => Promise; + /** Put item in material slot */ + putMaterial: (itemType: number | string, metadata: number | null, count: number) => Promise; + /** Take result */ + takeResult: () => Promise; + /** Close the anvil */ + close: () => void; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Open an anvil and return interface for repair/rename + * + * Note: Unlike other workstations, anvil in Bedrock doesn't require waiting for + * container_open. The client opens the anvil UI locally and directly sends + * item_stack_requests to anvil_input/anvil_material containers. + */ +export async function openAnvil(bot: BedrockBot, anvilBlock: Block): Promise { + // Send the interaction packet but don't wait for container_open + // Anvil is a "screen" that opens client-side without server confirmation + await bot.lookAt(anvilBlock.position.offset(0.5, 0.5, 0.5), true); + + // Send player_action: start_item_use_on to signal intent + const entityId = bot.entity.id; + const face = 1; // Click on top of anvil + bot._client.write('player_action', { + runtime_entity_id: entityId, + action: 'start_item_use_on', + position: { + x: anvilBlock.position.x, + y: anvilBlock.position.y, + z: anvilBlock.position.z, + }, + result_position: { + x: anvilBlock.position.x, + y: anvilBlock.position.y + 1, + z: anvilBlock.position.z, + }, + face: face, + }); + + // Send inventory_transaction to click on the anvil + bot._client.write('inventory_transaction', { + transaction: { + legacy: { legacy_request_id: 0 }, + transaction_type: 'item_use', + actions: [], + transaction_data: { + action_type: 'click_block', + trigger_type: 'player_input', + block_position: anvilBlock.position, + face: face, + hotbar_slot: bot.quickBarSlot ?? 0, + held_item: { network_id: 0 }, + player_pos: { + x: bot.entity.position.x, + y: bot.entity.position.y + 1.62, + z: bot.entity.position.z, + }, + click_pos: { x: 0.5, y: 0.5, z: 0.5 }, + block_runtime_id: (anvilBlock as any).stateId >>> 0, + client_prediction: 'success', + }, + }, + }); + + // Wait a moment for the server to acknowledge + await new Promise(r => setTimeout(r, 100)); + + // Create a minimal window object for compatibility + const windowLoader = await import('prismarine-windows'); + const windows = (windowLoader.default as any)(bot.registry); + const window = windows.createWindow(255, 'anvil', 'Anvil', 3); // 3 slots for anvil + + bot.logger.debug(`Opened anvil (no container_open needed)`); + + // Track stack IDs for items placed in anvil + let inputStackId = 0; + let materialStackId = 0; + + return { + window, + + async rename(newName: string): Promise { + // Use the tracked inputStackId from putTarget, or try to get it from window slots + const currentInputStackId = inputStackId || (window.slots[AnvilSlots.INPUT] ? getStackId(window.slots[AnvilSlots.INPUT]!) : 0); + + // Find an empty slot for output + const emptySlot = bot.inventory.slots.findIndex( + (s, i) => i >= bot.inventory.inventoryStart && i <= bot.inventory.inventoryEnd && !s + ); + const destSlot = emptySlot !== -1 ? emptySlot : 0; + + const requestId = getNextItemStackRequestId(); + + // Anvil rename uses 'optional' action + consume + place pattern + // Based on packet capture: {"type":"optional"},{"type":"consume","src":"anvil_input:1",...},{"type":"place","src":"creative_output:50",...,"dst":"hotbar_and_inventory:9",...} + const actionList: any[] = [ + { + type_id: 'optional', + filtered_string_index: 0, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.ANVIL_INPUT }, + slot: AnvilSlots.INPUT, + stack_id: currentInputStackId, + }, + }, + { + type_id: 'place', + count: 1, + source: { + slot_type: { container_id: ContainerIds.CREATIVE_OUTPUT }, + slot: CraftingSlots.CREATIVE_OUTPUT_SLOT, + stack_id: requestId, + }, + destination: { + slot_type: { container_id: ContainerIds.HOTBAR_AND_INVENTORY }, + slot: destSlot, + stack_id: 0, + }, + }, + ]; + + bot._client.write('item_stack_request', { + requests: [ + { + request_id: requestId, + actions: actionList, + custom_names: [newName], + cause: -1, + }, + ], + }); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Anvil rename failed'); + } + }, + + async combine(): Promise { + // Get stack IDs from items in anvil slots + const inputItem = window.slots[AnvilSlots.INPUT]; + const materialItem = window.slots[AnvilSlots.MATERIAL]; + const inputStackId = inputItem ? getStackId(inputItem) : 0; + const materialStackId = materialItem ? getStackId(materialItem) : 0; + + // Find an empty slot for output + const emptySlot = bot.inventory.slots.findIndex( + (s, i) => i >= bot.inventory.inventoryStart && i <= bot.inventory.inventoryEnd && !s + ); + const destSlot = emptySlot !== -1 ? emptySlot : 0; + + const requestId = getNextItemStackRequestId(); + + // Based on packet capture: optional + consume material + consume input + place + const actionList: any[] = [ + { + type_id: 'optional', + filtered_string_index: 0, + }, + { + type_id: 'consume', + count: materialItem?.count || 1, + source: { + slot_type: { container_id: ContainerIds.ANVIL_MATERIAL }, + slot: AnvilSlots.MATERIAL, + stack_id: materialStackId, + }, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.ANVIL_INPUT }, + slot: AnvilSlots.INPUT, + stack_id: inputStackId, + }, + }, + { + type_id: 'place', + count: 1, + source: { + slot_type: { container_id: ContainerIds.CREATIVE_OUTPUT }, + slot: CraftingSlots.CREATIVE_OUTPUT_SLOT, + stack_id: requestId, + }, + destination: { + slot_type: { container_id: ContainerIds.HOTBAR_AND_INVENTORY }, + slot: destSlot, + stack_id: 0, + }, + }, + ]; + + bot._client.write('item_stack_request', { + requests: [ + { + request_id: requestId, + actions: actionList, + custom_names: [''], + cause: -1, + }, + ], + }); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Anvil combine failed'); + } + }, + + async putTarget(itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Item ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + + // Use two-step cursor transfer for anvil (like stonecutter) + const result = await twoStepTransfer( + bot, + { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, + { containerId: ContainerIds.ANVIL_INPUT, slot: AnvilSlots.INPUT, stackId: 0 }, + 1 + ); + + if (!result.success) { + throw new Error('Failed to put item in anvil'); + } + + // Track the stack ID for later use in rename + inputStackId = result.cursorStackId; + }, + + async putMaterial(itemType: number | string, metadata: number | null, count: number): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Material ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + + // Use two-step cursor transfer for anvil + const result = await twoStepTransfer( + bot, + { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, + { containerId: ContainerIds.ANVIL_MATERIAL, slot: AnvilSlots.MATERIAL, stackId: 0 }, + count + ); + + if (!result.success) { + throw new Error('Failed to put material in anvil'); + } + + // Track the stack ID for later use + materialStackId = result.cursorStackId; + }, + + async takeResult(): Promise { + // Result is taken via combine/rename actions + return null; + }, + + close() { + bot.closeWindow(window); + }, + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/brewing.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/brewing.mts new file mode 100644 index 0000000..d4b45ee --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/brewing.mts @@ -0,0 +1,211 @@ +/** + * Brewing Stand Workstation - Potion brewing operations for Bedrock protocol + * + * Container IDs from packet captures: + * - brewing_input: slot 0 (ingredient like nether wart, sugar) + * - brewing_result: slots 1, 2, 3 (three potion bottle slots) + * - brewing_fuel: slot 4 (blaze powder) + * + * Pattern: Place items → server auto-brews (no craft action needed) + * Similar to furnace - just place items and wait. + */ + +import type { Item } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../../index.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds } from '../item-stack-actions.mts'; +import { findItemInAllSlots } from '../crafting-core.mts'; +import { twoStepTransfer } from '../container.mts'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const BrewingSlots = { + INGREDIENT: 0, + RESULT_1: 1, + RESULT_2: 2, + RESULT_3: 3, + FUEL: 4, +} as const; + +// ============================================================================ +// Brewing Stand Interface +// ============================================================================ + +export interface BrewingStand { + window: Window; + /** Put blaze powder as fuel */ + putFuel: (count: number) => Promise; + /** Put ingredient (nether wart, sugar, etc.) */ + putIngredient: (itemType: number | string, metadata: number | null) => Promise; + /** Put potion bottle in result slot (1-3) */ + putBottle: (slot: 1 | 2 | 3, itemType: number | string, metadata: number | null) => Promise; + /** Take bottle from result slot (1-3) */ + takeBottle: (slot: 1 | 2 | 3) => Promise; + /** Get fuel item */ + fuelItem: () => Item | null; + /** Get ingredient item */ + ingredientItem: () => Item | null; + /** Get bottle in slot (1-3) */ + bottleItem: (slot: 1 | 2 | 3) => Item | null; + /** Fuel remaining (0-1) */ + fuel: number; + /** Brewing progress (0-1) */ + progress: number; + /** Close the brewing stand */ + close: () => void; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Open a brewing stand block and return interface for brewing + */ +export async function openBrewingStand(bot: BedrockBot, brewingBlock: Block): Promise { + const window = await bot.openBlock(brewingBlock); + bot.logger.debug(`Opened brewing stand window: ${window?.id}`); + + // Track fuel and progress from container_set_data packet + let fuelProgress = 0; + let brewProgress = 0; + + // Listen for container data updates (brewing progress) + const dataHandler = (packet: any) => { + if (packet.window_id !== window.id) return; + + // Property 0 = brewing time remaining + // Property 1 = fuel amount + if (packet.property === 0) { + // Brewing progress - 400 ticks = full brew + brewProgress = Math.min(1, 1 - (packet.value / 400)); + } else if (packet.property === 1) { + // Fuel - 20 is full + fuelProgress = packet.value / 20; + } + }; + bot._client.on('container_set_data', dataHandler); + + const brewing: BrewingStand = { + window, + + get fuel() { return fuelProgress; }, + get progress() { return brewProgress; }, + + async putFuel(count: number): Promise { + const foundItem = findItemInAllSlots(bot, 'blaze_powder', null); + if (!foundItem) { + throw new Error('Blaze powder not found in inventory'); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .place(count, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, { containerId: ContainerIds.BREWING_FUEL, slot: BrewingSlots.FUEL, stackId: 0 }) + .build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to put fuel in brewing stand'); + } + }, + + async putIngredient(itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Ingredient ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .place(1, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, { containerId: ContainerIds.BREWING_INPUT, slot: BrewingSlots.INGREDIENT, stackId: 0 }) + .build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to put ingredient in brewing stand'); + } + }, + + async putBottle(slot: 1 | 2 | 3, itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Potion/bottle ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const targetSlot = slot; // slots 1, 2, 3 map directly + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .place(1, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, { containerId: ContainerIds.BREWING_RESULT, slot: targetSlot, stackId: 0 }) + .build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error(`Failed to put bottle in brewing stand slot ${slot}`); + } + }, + + async takeBottle(slot: 1 | 2 | 3): Promise { + const targetSlot = slot; // slots 1, 2, 3 map directly + const item = window.slots[targetSlot]; + if (!item) return null; + + const stackId = getStackId(item); + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .takeToCursor(1, { containerId: ContainerIds.BREWING_RESULT, slot: targetSlot, stackId }) + .build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error(`Failed to take bottle from brewing stand slot ${slot}`); + } + + await bot.putAway(0); + return item; + }, + + fuelItem(): Item | null { + return window.slots[BrewingSlots.FUEL] || null; + }, + + ingredientItem(): Item | null { + return window.slots[BrewingSlots.INGREDIENT] || null; + }, + + bottleItem(slot: 1 | 2 | 3): Item | null { + return window.slots[slot] || null; + }, + + close() { + bot._client.removeListener('container_set_data', dataHandler); + bot.closeWindow(window); + }, + }; + + return brewing; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/cartography.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/cartography.mts new file mode 100644 index 0000000..82af9a9 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/cartography.mts @@ -0,0 +1,206 @@ +/** + * Cartography Table Workstation - Map operations for Bedrock protocol + * + * Container IDs from packet captures: + * - cartography_input: slot 12 (main map) + * - cartography_additional: slot 13 (paper/empty map/glass pane) + * + * Pattern from captures: + * optional + consume(cartography_input:12) + consume(cartography_additional:13) + + * take(creative_output → cursor) + * + * Operations: + * - Clone map: map + empty_map → 2 identical maps + * - Extend map: map + paper → larger map + * - Lock map: map + glass_pane → locked (non-updating) map + */ + +import type { Item } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../../index.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds, cursor } from '../item-stack-actions.mts'; +import { findItemInAllSlots, CraftingSlots } from '../crafting-core.mts'; +import { twoStepTransfer } from '../container.mts'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const CartographySlots = { + INPUT: 12, + ADDITIONAL: 13, +} as const; + +// ============================================================================ +// Cartography Table Interface +// ============================================================================ + +export interface CartographyTable { + window: Window; + /** Put map in input slot */ + putMap: (itemType: number | string, metadata: number | null) => Promise; + /** Put paper in additional slot (for extending) */ + putPaper: () => Promise; + /** Put empty map in additional slot (for cloning) */ + putEmptyMap: () => Promise; + /** Put glass pane in additional slot (for locking) */ + putGlassPane: () => Promise; + /** Execute the cartography operation (clone/extend/lock) */ + craft: () => Promise; + /** Take result from cartography table */ + takeResult: () => Promise; + /** Get current map item */ + mapItem: () => Item | null; + /** Get current additional item */ + additionalItem: () => Item | null; + /** Close the cartography table */ + close: () => void; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Open a cartography table block and return interface for map operations + */ +export async function openCartographyTable(bot: BedrockBot, cartographyBlock: Block): Promise { + const window = await bot.openBlock(cartographyBlock); + bot.logger.debug(`Opened cartography table window: ${window?.id}`); + + // Track stack IDs of placed items + let mapStackId = 0; + let additionalStackId = 0; + + async function putItem(containerId: string, slot: number, itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Item ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + + const result = await twoStepTransfer( + bot, + { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, + { containerId, slot, stackId: 0 }, + 1 + ); + + if (!result.success) { + throw new Error(`Failed to place ${itemType} in cartography table`); + } + + return result.cursorStackId; + } + + return { + window, + + async putMap(itemType: number | string, metadata: number | null): Promise { + mapStackId = await putItem(ContainerIds.CARTOGRAPHY_INPUT, CartographySlots.INPUT, itemType, metadata); + }, + + async putPaper(): Promise { + additionalStackId = await putItem(ContainerIds.CARTOGRAPHY_ADDITIONAL, CartographySlots.ADDITIONAL, 'paper', null); + }, + + async putEmptyMap(): Promise { + additionalStackId = await putItem(ContainerIds.CARTOGRAPHY_ADDITIONAL, CartographySlots.ADDITIONAL, 'empty_map', null); + }, + + async putGlassPane(): Promise { + additionalStackId = await putItem(ContainerIds.CARTOGRAPHY_ADDITIONAL, CartographySlots.ADDITIONAL, 'glass_pane', null); + }, + + async craft(): Promise { + // Get current stack IDs from window slots + const mapItem = window.slots[CartographySlots.INPUT]; + const addItem = window.slots[CartographySlots.ADDITIONAL]; + + if (!mapItem || !addItem) { + throw new Error('Map and additional item required in cartography table'); + } + + const currentMapStackId = getStackId(mapItem) || mapStackId; + const currentAdditionalStackId = getStackId(addItem) || additionalStackId; + + // Need to wait a bit for server to register the placed items + await new Promise((r) => setTimeout(r, 200)); + + const requestId = getNextItemStackRequestId(); + + // Pattern from packet captures: + // optional + consume(input) + consume(additional) + take(output) + // Note: Cartography uses 'optional' instead of craft_recipe + const actionList: any[] = [ + { + type_id: 'optional', + filtered_string_index: 0, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.CARTOGRAPHY_INPUT }, + slot: CartographySlots.INPUT, + stack_id: currentMapStackId, + }, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.CARTOGRAPHY_ADDITIONAL }, + slot: CartographySlots.ADDITIONAL, + stack_id: currentAdditionalStackId, + }, + }, + { + type_id: 'take', + count: 2, // Clone produces 2 maps + source: { + slot_type: { container_id: ContainerIds.CREATIVE_OUTPUT }, + slot: CraftingSlots.CREATIVE_OUTPUT_SLOT, + stack_id: requestId, + }, + destination: { + slot_type: { container_id: ContainerIds.CURSOR }, + slot: 0, + stack_id: 0, + }, + }, + ]; + + bot.logger.debug(`Cartography craft: mapStackId=${currentMapStackId}, additionalStackId=${currentAdditionalStackId}`); + sendRequest(bot, requestId, actionList); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Cartography table craft failed'); + } + + // Put result away from cursor + await bot.putAway(0); + }, + + async takeResult(): Promise { + // Result is already in cursor after craft, just put away + await bot.putAway(0); + return null; + }, + + mapItem(): Item | null { + return window.slots[CartographySlots.INPUT] || null; + }, + + additionalItem(): Item | null { + return window.slots[CartographySlots.ADDITIONAL] || null; + }, + + close() { + bot.closeWindow(window); + }, + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/enchanting.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/enchanting.mts new file mode 100644 index 0000000..e60c551 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/enchanting.mts @@ -0,0 +1,368 @@ +/** + * Enchanting Table Workstation - Enchanting operations for Bedrock protocol + * + * Container IDs from packet captures: + * - enchanting_input: slot 14 + * - enchanting_lapis: slot 15 + */ + +import type { Item } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../../index.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds } from '../item-stack-actions.mts'; +import { findItemInAllSlots, CraftingSlots } from '../crafting-core.mts'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const EnchantingSlots = { + INPUT: 14, + LAPIS: 15, +} as const; + +// ============================================================================ +// Enchantment Table Interface +// ============================================================================ + +export interface EnchantmentTable { + window: Window; + /** Enchant item. enchantSlot is 0-2 for the three enchant options */ + enchant: (enchantSlot: number) => Promise; + /** Put item to enchant */ + putItem: (itemType: number | string, metadata: number | null) => Promise; + /** Put lapis lazuli */ + putLapis: (count: number) => Promise; + /** Take enchanted item back */ + takeItem: () => Promise; + /** Close the enchanting table */ + close: () => void; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Open an enchanting table and return interface for enchanting + */ +export async function openEnchantmentTable(bot: BedrockBot, enchantTableBlock: Block): Promise { + const window = await bot.openBlock(enchantTableBlock); + bot.logger.debug(`Opened enchanting table window: ${window?.id}`); + + // Track enchant options from server + let enchantOptions: any[] = []; + let inputStackId = 0; + let lapisStackId = 0; + let inputItemInfo: { network_id: number; count: number; metadata: number; block_runtime_id: number; extra: any } | null = null; + + // Listen for enchant options from server + const optionsHandler = (packet: any) => { + enchantOptions = packet.options || []; + bot.logger.debug(`Received ${enchantOptions.length} enchant options`); + }; + bot._client.on('player_enchant_options', optionsHandler); + + return { + window, + + /** + * Get available enchantment options + */ + getOptions(): any[] { + return enchantOptions; + }, + + /** + * Enchant item using the option at the given index (0-2) + * The enchantSlot must be an option_id from player_enchant_options packet + * + * Pattern from packet captures (verified working): + * 1. craft_recipe(recipeNetworkId) + results_deprecated + * 2. consume(enchanting_input:14) + * 3. place(creative_output → enchanting_input:14) - result goes BACK to input slot + * 4. consume(enchanting_lapis:15) - lapis consumed AFTER placing result + */ + async enchant(enchantSlot: number): Promise { + // If we have options, use the option_id from the options list + // The option_id is received as zigzag32, but recipe_network_id needs the unsigned encoding + // zigzag encode: (n << 1) ^ (n >> 31) for 32-bit + let optionId = enchantSlot; + if (enchantOptions.length > 0) { + if (enchantSlot >= 0 && enchantSlot < enchantOptions.length) { + const rawOptionId = enchantOptions[enchantSlot].option_id; + // Convert zigzag-decoded value back to unsigned varint: zigzag encode + optionId = (rawOptionId << 1) ^ (rawOptionId >> 31); + bot.logger.debug(`Using enchant option ${enchantSlot}: rawOptionId=${rawOptionId}, encoded=${optionId}`); + } else { + throw new Error(`Invalid enchant slot ${enchantSlot}, only ${enchantOptions.length} options available`); + } + } + + // Get the lapis cost from enchant options (defaults to 1 for level 1) + const lapisCost = enchantOptions.length > 0 ? (enchantOptions[enchantSlot].cost || 1) : 1; + + const requestId = getNextItemStackRequestId(); + + // Pattern from packet captures: + // craft_recipe + results_deprecated + consume(input) + place(output→input) + consume(lapis) + // Include the input item as result (required to prevent server crash) + const resultItems = inputItemInfo ? [{ + network_id: inputItemInfo.network_id, + count: inputItemInfo.count, + metadata: inputItemInfo.metadata, + block_runtime_id: inputItemInfo.block_runtime_id, + extra: inputItemInfo.extra, + }] : []; + + const actionList: any[] = [ + { + type_id: 'craft_recipe', + recipe_network_id: optionId, // Use option_id from enchant options + times_crafted: 1, + }, + { + type_id: 'results_deprecated', + result_items: resultItems, + times_crafted: 1, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.ENCHANTING_INPUT }, + slot: EnchantingSlots.INPUT, + stack_id: inputStackId, + }, + }, + { + // Result placed BACK to enchanting_input slot (key pattern!) + type_id: 'place', + count: 1, + source: { + slot_type: { container_id: ContainerIds.CREATIVE_OUTPUT }, + slot: CraftingSlots.CREATIVE_OUTPUT_SLOT, + stack_id: requestId, // Use -requestId as source stack + }, + destination: { + slot_type: { container_id: ContainerIds.ENCHANTING_INPUT }, + slot: EnchantingSlots.INPUT, + stack_id: requestId, // Use -requestId as dest stack (same as source) + }, + }, + { + // Consume lapis AFTER placing result back + type_id: 'consume', + count: lapisCost, + source: { + slot_type: { container_id: ContainerIds.ENCHANTING_LAPIS }, + slot: EnchantingSlots.LAPIS, + stack_id: lapisStackId, + }, + }, + ]; + + bot.logger.debug(`Enchanting: optionId=${optionId}, inputStackId=${inputStackId}, lapisStackId=${lapisStackId}, lapisCost=${lapisCost}`); + + // Capture the new stackId from the response + const captureHandler = (packet: any) => { + for (const resp of packet.responses || []) { + if (resp.request_id === requestId && resp.status === 'ok') { + for (const container of resp.containers || []) { + if (container.slot_type?.container_id === 'enchanting_input') { + for (const slot of container.slots || []) { + if (slot.slot === EnchantingSlots.INPUT && slot.item_stack_id > 0) { + inputStackId = slot.item_stack_id; + bot.logger.debug(`Captured enchanted item stackId: ${inputStackId}`); + } + } + } + } + } + } + }; + bot._client.once('item_stack_response', captureHandler); + + sendRequest(bot, requestId, actionList); + + if (!(await waitForResponse(bot, requestId))) { + bot._client.removeListener('item_stack_response', captureHandler); + throw new Error('Enchanting failed'); + } + + // Clear options after enchanting + enchantOptions = []; + }, + + async putItem(itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Item ${itemType} not found in inventory`); + } + + // Store item info for results_deprecated - ItemLegacy format + // network_id of 0 means empty slot (void), so we only need network_id for non-empty + inputItemInfo = { + network_id: (foundItem as any).nid ?? foundItem.type, + count: 1, + metadata: foundItem.metadata ?? 0, + block_runtime_id: 0, + extra: { has_nbt: 0, can_place_on: [], can_destroy: [] }, + }; + bot.logger.debug(`Stored input item info: network_id=${inputItemInfo.network_id}`); + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const requestId = getNextItemStackRequestId(); + + // Use take + place through cursor like real client + // Step 1: Take item to cursor + const takeRequestId = requestId; + sendRequest( + bot, + takeRequestId, + actions() + .takeToCursor(1, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }) + .build() + ); + + if (!(await waitForResponse(bot, takeRequestId))) { + throw new Error('Failed to take item to cursor'); + } + + // Step 2: Place from cursor to enchanting_input + const placeRequestId = getNextItemStackRequestId(); + let capturedStackId = stackId; + const captureHandler = (packet: any) => { + for (const resp of packet.responses || []) { + if (resp.request_id === placeRequestId && resp.status === 'ok') { + for (const container of resp.containers || []) { + if (container.slot_type?.container_id === 'enchanting_input') { + for (const slot of container.slots || []) { + if (slot.slot === EnchantingSlots.INPUT && slot.item_stack_id > 0) { + capturedStackId = slot.item_stack_id; + } + } + } + } + } + } + }; + bot._client.once('item_stack_response', captureHandler); + + sendRequest( + bot, + placeRequestId, + actions() + .place(1, { containerId: ContainerIds.CURSOR, slot: 0, stackId }, { containerId: ContainerIds.ENCHANTING_INPUT, slot: EnchantingSlots.INPUT, stackId: 0 }) + .build() + ); + + if (!(await waitForResponse(bot, placeRequestId))) { + bot._client.removeListener('item_stack_response', captureHandler); + throw new Error('Failed to put item in enchanting table'); + } + + inputStackId = capturedStackId; + bot.logger.debug(`Enchanting input stackId: ${inputStackId}`); + + // Wait a bit for server to send enchant options + await new Promise(r => setTimeout(r, 300)); + }, + + async putLapis(count: number): Promise { + const foundItem = findItemInAllSlots(bot, 'lapis_lazuli', null); + if (!foundItem) { + throw new Error('Lapis lazuli not found in inventory'); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const totalCount = foundItem.count; + const requestId = getNextItemStackRequestId(); + + // Use take + place through cursor like real client + // Step 1: Take lapis to cursor (take all) + sendRequest( + bot, + requestId, + actions() + .takeToCursor(totalCount, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }) + .build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to take lapis to cursor'); + } + + // Step 2: Place count lapis from cursor to enchanting_lapis + const placeRequestId = getNextItemStackRequestId(); + let capturedStackId = stackId; + const captureHandler = (packet: any) => { + for (const resp of packet.responses || []) { + if (resp.request_id === placeRequestId && resp.status === 'ok') { + for (const container of resp.containers || []) { + if (container.slot_type?.container_id === 'enchanting_lapis') { + for (const slot of container.slots || []) { + if (slot.slot === EnchantingSlots.LAPIS && slot.item_stack_id > 0) { + capturedStackId = slot.item_stack_id; + } + } + } + } + } + } + }; + bot._client.once('item_stack_response', captureHandler); + + sendRequest( + bot, + placeRequestId, + actions() + .place(count, { containerId: ContainerIds.CURSOR, slot: 0, stackId }, { containerId: ContainerIds.ENCHANTING_LAPIS, slot: EnchantingSlots.LAPIS, stackId: 0 }) + .build() + ); + + if (!(await waitForResponse(bot, placeRequestId))) { + bot._client.removeListener('item_stack_response', captureHandler); + throw new Error('Failed to put lapis in enchanting table'); + } + + lapisStackId = capturedStackId; + bot.logger.debug(`Enchanting lapis stackId: ${lapisStackId}`); + + // If we had more lapis than needed, put the rest back + if (totalCount > count) { + const putBackRequestId = getNextItemStackRequestId(); + sendRequest( + bot, + putBackRequestId, + actions() + .place(totalCount - count, { containerId: ContainerIds.CURSOR, slot: 0, stackId }, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId: 0 }) + .build() + ); + await waitForResponse(bot, putBackRequestId); + } + }, + + async takeItem(): Promise { + const requestId = getNextItemStackRequestId(); + + // Use the captured stackId from enchanting response + sendRequest(bot, requestId, actions().takeToCursor(1, { containerId: ContainerIds.ENCHANTING_INPUT, slot: EnchantingSlots.INPUT, stackId: inputStackId }).build()); + + if (!(await waitForResponse(bot, requestId))) { + return null; + } + + await bot.putAway(0); + return null; // Would need to track the item + }, + + close() { + bot._client.removeListener('player_enchant_options', optionsHandler); + bot.closeWindow(window); + }, + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/furnace.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/furnace.mts new file mode 100644 index 0000000..dcdb0bd --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/furnace.mts @@ -0,0 +1,216 @@ +/** + * Furnace Workstation - Smelting operations for Bedrock protocol + * + * Container IDs from packet captures: + * - furnace_ingredient: slot 0 + * - furnace_fuel: slot 1 + * - furnace_output: slot 2 + */ + +import type { Item } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../../index.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds } from '../item-stack-actions.mts'; +import { findItemInAllSlots } from '../crafting-core.mts'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const FurnaceSlots = { + INGREDIENT: 0, + FUEL: 1, + OUTPUT: 2, +} as const; + +// ============================================================================ +// Furnace Interface +// ============================================================================ + +export interface Furnace { + window: Window; + /** Put item in ingredient slot */ + putIngredient: (itemType: number | string, metadata: number | null, count: number) => Promise; + /** Put item in fuel slot */ + putFuel: (itemType: number | string, metadata: number | null, count: number) => Promise; + /** Take item from ingredient slot */ + takeInput: () => Promise; + /** Take item from fuel slot */ + takeFuel: () => Promise; + /** Take item from output slot */ + takeOutput: () => Promise; + /** Get current ingredient item */ + inputItem: () => Item | null; + /** Get current fuel item */ + fuelItem: () => Item | null; + /** Get current output item */ + outputItem: () => Item | null; + /** Fuel progress (0-1) - percentage of fuel remaining */ + fuel: number; + /** Smelting progress (0-1) - percentage of current item smelted */ + progress: number; + /** Close the furnace */ + close: () => void; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Open a furnace block and return interface for smelting + */ +export async function openFurnace(bot: BedrockBot, furnaceBlock: Block): Promise { + const window = await bot.openBlock(furnaceBlock); + bot.logger.debug(`Opened furnace window: ${window?.id}`); + + // Track fuel and progress from container_set_data packet + let fuelProgress = 0; + let smeltProgress = 0; + + // Listen for container data updates (furnace progress bars) + // Bedrock sends container_set_data with property IDs: + // 0 = furnace tick count (smelting progress) + // 1 = furnace lit time (fuel remaining) + // 2 = furnace lit duration (max fuel time) + // 3 = furnace tick count total (max smelting time) + const dataHandler = (packet: any) => { + if (packet.window_id !== window.id) return; + + // Property 0/3 = smelting progress + // Property 1/2 = fuel progress + // Values are raw tick counts, convert to 0-1 range + if (packet.property === 0 || packet.property === 3) { + // Smelting progress - 200 ticks = full smelt + smeltProgress = Math.min(1, packet.value / 200); + } else if (packet.property === 1 || packet.property === 2) { + // Fuel progress - depends on fuel type + if (packet.property === 1 && packet.value > 0) { + fuelProgress = packet.value / 1600; // Coal = 1600 ticks + } + } + }; + bot._client.on('container_set_data', dataHandler); + + const furnace: Furnace = { + window, + + get fuel() { return fuelProgress; }, + get progress() { return smeltProgress; }, + + async putIngredient(itemType: number | string, metadata: number | null, count: number): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Item ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .place(count, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, { containerId: ContainerIds.FURNACE_INGREDIENT, slot: FurnaceSlots.INGREDIENT, stackId: 0 }) + .build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to put ingredient in furnace'); + } + }, + + async putFuel(itemType: number | string, metadata: number | null, count: number): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Fuel ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions().place(count, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, { containerId: ContainerIds.FURNACE_FUEL, slot: FurnaceSlots.FUEL, stackId: 0 }).build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to put fuel in furnace'); + } + }, + + async takeInput(): Promise { + const inputItem = window.slots[FurnaceSlots.INGREDIENT]; + if (!inputItem) return null; + + const stackId = getStackId(inputItem); + const requestId = getNextItemStackRequestId(); + + sendRequest(bot, requestId, actions().takeToCursor(inputItem.count, { containerId: ContainerIds.FURNACE_INGREDIENT, slot: FurnaceSlots.INGREDIENT, stackId }).build()); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to take input from furnace'); + } + + await bot.putAway(0); + return inputItem; + }, + + async takeFuel(): Promise { + const fuelItem = window.slots[FurnaceSlots.FUEL]; + if (!fuelItem) return null; + + const stackId = getStackId(fuelItem); + const requestId = getNextItemStackRequestId(); + + sendRequest(bot, requestId, actions().takeToCursor(fuelItem.count, { containerId: ContainerIds.FURNACE_FUEL, slot: FurnaceSlots.FUEL, stackId }).build()); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to take fuel from furnace'); + } + + await bot.putAway(0); + return fuelItem; + }, + + async takeOutput(): Promise { + const outputItem = window.slots[FurnaceSlots.OUTPUT]; + if (!outputItem) return null; + + const stackId = getStackId(outputItem); + const requestId = getNextItemStackRequestId(); + + sendRequest(bot, requestId, actions().takeToCursor(outputItem.count, { containerId: ContainerIds.FURNACE_OUTPUT, slot: FurnaceSlots.OUTPUT, stackId }).build()); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to take output from furnace'); + } + + await bot.putAway(0); + return outputItem; + }, + + inputItem(): Item | null { + return window.slots[FurnaceSlots.INGREDIENT] || null; + }, + + fuelItem(): Item | null { + return window.slots[FurnaceSlots.FUEL] || null; + }, + + outputItem(): Item | null { + return window.slots[FurnaceSlots.OUTPUT] || null; + }, + + close() { + bot._client.removeListener('container_set_data', dataHandler); + bot.closeWindow(window); + }, + }; + + return furnace; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/grindstone.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/grindstone.mts new file mode 100644 index 0000000..34600d0 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/grindstone.mts @@ -0,0 +1,167 @@ +/** + * Grindstone Workstation - Disenchanting operations for Bedrock protocol + * + * Container IDs from packet captures: + * - grindstone_input: slot 16 + * + * Pattern from captures: + * craft_grindstone_request(recipeNetworkId) + results_deprecated + + * consume(grindstone_input:16) + take(creative_output → cursor) + */ + +import type { Item } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../../index.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds, cursor } from '../item-stack-actions.mts'; +import { findItemInAllSlots, CraftingSlots } from '../crafting-core.mts'; +import { twoStepTransfer } from '../container.mts'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const GrindstoneSlots = { + INPUT: 16, +} as const; + +// ============================================================================ +// Grindstone Interface +// ============================================================================ + +export interface Grindstone { + window: Window; + /** Put enchanted item in input slot */ + putItem: (itemType: number | string, metadata: number | null) => Promise; + /** Disenchant the item (removes enchantments, returns XP) */ + disenchant: () => Promise; + /** Take result from grindstone */ + takeResult: () => Promise; + /** Get current input item */ + inputItem: () => Item | null; + /** Close the grindstone */ + close: () => void; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Open a grindstone block and return interface for disenchanting + */ +export async function openGrindstone(bot: BedrockBot, grindstoneBlock: Block): Promise { + const window = await bot.openBlock(grindstoneBlock); + bot.logger.debug(`Opened grindstone window: ${window?.id}`); + + // Track stack ID of placed item + let inputStackId = 0; + + return { + window, + + async putItem(itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Item ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + + // Use two-step transfer (via cursor) like stonecutter + const result = await twoStepTransfer( + bot, + { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, + { containerId: ContainerIds.GRINDSTONE_INPUT, slot: GrindstoneSlots.INPUT, stackId: 0 }, + 1 + ); + + if (!result.success) { + throw new Error('Failed to place item in grindstone'); + } + + // Track the stack ID for later disenchant + inputStackId = result.cursorStackId; + }, + + async disenchant(): Promise { + // Get the current stack ID from window slot + const inputItem = window.slots.find((s) => s != null); + const currentStackId = inputItem ? getStackId(inputItem) : inputStackId; + + if (!inputItem) { + throw new Error('No item in grindstone to disenchant'); + } + + // Need to wait a bit for server to register the placed item + await new Promise((r) => setTimeout(r, 200)); + + const requestId = getNextItemStackRequestId(); + + // Pattern from packet captures: + // craft_grindstone_request + results_deprecated + consume + take + const actionList: any[] = [ + { + type_id: 'craft_grindstone_request', + // Grindstone uses a dynamic recipe ID based on the item + // The server calculates it, we can use a placeholder + recipe_network_id: 6117, // This is item-specific, may need adjustment + times_crafted: 1, + }, + { + type_id: 'results_deprecated', + result_items: [], + times_crafted: 1, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.GRINDSTONE_INPUT }, + slot: GrindstoneSlots.INPUT, + stack_id: currentStackId, + }, + }, + { + type_id: 'take', + count: 1, + source: { + slot_type: { container_id: ContainerIds.CREATIVE_OUTPUT }, + slot: CraftingSlots.CREATIVE_OUTPUT_SLOT, + stack_id: requestId, + }, + destination: { + slot_type: { container_id: ContainerIds.CURSOR }, + slot: 0, + stack_id: 0, + }, + }, + ]; + + bot.logger.debug(`Grindstone disenchant: stackId=${currentStackId}`); + sendRequest(bot, requestId, actionList); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Grindstone disenchant failed'); + } + + // Put result away from cursor + await bot.putAway(0); + }, + + async takeResult(): Promise { + // Result is already in cursor after disenchant, just put away + await bot.putAway(0); + return null; + }, + + inputItem(): Item | null { + return window.slots[GrindstoneSlots.INPUT] || null; + }, + + close() { + bot.closeWindow(window); + }, + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/index.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/index.mts new file mode 100644 index 0000000..522659e --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/index.mts @@ -0,0 +1,13 @@ +/** + * Workstations Index - Re-exports all workstation modules + */ + +export { openFurnace, FurnaceSlots, type Furnace } from './furnace.mts'; +export { openAnvil, AnvilSlots, type Anvil } from './anvil.mts'; +export { openEnchantmentTable, EnchantingSlots, type EnchantmentTable } from './enchanting.mts'; +export { openSmithingTable, SmithingSlots, type SmithingTable } from './smithing.mts'; +export { openStonecutter, StonecutterSlots, type Stonecutter } from './stonecutter.mts'; +export { openGrindstone, GrindstoneSlots, type Grindstone } from './grindstone.mts'; +export { openLoom, LoomSlots, type Loom } from './loom.mts'; +export { openBrewingStand, BrewingSlots, type BrewingStand } from './brewing.mts'; +export { openCartographyTable, CartographySlots, type CartographyTable } from './cartography.mts'; diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/loom.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/loom.mts new file mode 100644 index 0000000..87d1ff0 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/loom.mts @@ -0,0 +1,209 @@ +/** + * Loom Workstation - Banner pattern operations for Bedrock protocol + * + * Container IDs from packet captures: + * - loom_input: slot 9 (banner) + * - loom_dye: slot 10 (dye) + * + * Pattern from captures (NO recipeNetworkId!): + * craft_loom_request(timesCrafted) + results_deprecated + + * consume(loom_input:9) + consume(loom_dye:10) + take(creative_output → cursor) + */ + +import type { Item } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../../index.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds, cursor } from '../item-stack-actions.mts'; +import { findItemInAllSlots, CraftingSlots } from '../crafting-core.mts'; +import { twoStepTransfer } from '../container.mts'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const LoomSlots = { + BANNER: 9, + DYE: 10, +} as const; + +// ============================================================================ +// Loom Interface +// ============================================================================ + +export interface Loom { + window: Window; + /** Put banner in input slot */ + putBanner: (itemType: number | string, metadata: number | null) => Promise; + /** Put dye in dye slot */ + putDye: (itemType: number | string, metadata: number | null) => Promise; + /** Apply pattern to banner (pattern selection is done via UI, this just confirms) */ + applyPattern: () => Promise; + /** Take result from loom */ + takeResult: () => Promise; + /** Get current banner item */ + bannerItem: () => Item | null; + /** Get current dye item */ + dyeItem: () => Item | null; + /** Close the loom */ + close: () => void; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Open a loom block and return interface for banner patterns + */ +export async function openLoom(bot: BedrockBot, loomBlock: Block): Promise { + const window = await bot.openBlock(loomBlock); + bot.logger.debug(`Opened loom window: ${window?.id}`); + + // Track stack IDs of placed items + let bannerStackId = 0; + let dyeStackId = 0; + + return { + window, + + async putBanner(itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Banner ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + + const result = await twoStepTransfer( + bot, + { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, + { containerId: ContainerIds.LOOM_INPUT, slot: LoomSlots.BANNER, stackId: 0 }, + 1 + ); + + if (!result.success) { + throw new Error('Failed to place banner in loom'); + } + + bannerStackId = result.cursorStackId; + }, + + async putDye(itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Dye ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + + const result = await twoStepTransfer( + bot, + { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, + { containerId: ContainerIds.LOOM_DYE, slot: LoomSlots.DYE, stackId: 0 }, + 1 + ); + + if (!result.success) { + throw new Error('Failed to place dye in loom'); + } + + dyeStackId = result.cursorStackId; + }, + + async applyPattern(): Promise { + // Get current stack IDs from window slots + const bannerItem = window.slots[LoomSlots.BANNER]; + const dyeItem = window.slots[LoomSlots.DYE]; + + if (!bannerItem || !dyeItem) { + throw new Error('Banner and dye required in loom'); + } + + const currentBannerStackId = getStackId(bannerItem) || bannerStackId; + const currentDyeStackId = getStackId(dyeItem) || dyeStackId; + + // Need to wait a bit for server to register the placed items + await new Promise((r) => setTimeout(r, 200)); + + const requestId = getNextItemStackRequestId(); + + // Pattern from packet captures: + // craft_loom_request (NO recipe_network_id!) + results_deprecated + 2x consume + take + const actionList: any[] = [ + { + type_id: 'craft_loom_request', + times_crafted: 1, + // Note: Loom does NOT have recipe_network_id, pattern is selected via UI + }, + { + type_id: 'results_deprecated', + result_items: [], + times_crafted: 1, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.LOOM_INPUT }, + slot: LoomSlots.BANNER, + stack_id: currentBannerStackId, + }, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.LOOM_DYE }, + slot: LoomSlots.DYE, + stack_id: currentDyeStackId, + }, + }, + { + type_id: 'take', + count: 1, + source: { + slot_type: { container_id: ContainerIds.CREATIVE_OUTPUT }, + slot: CraftingSlots.CREATIVE_OUTPUT_SLOT, + stack_id: requestId, + }, + destination: { + slot_type: { container_id: ContainerIds.CURSOR }, + slot: 0, + stack_id: 0, + }, + }, + ]; + + bot.logger.debug(`Loom apply pattern: bannerStackId=${currentBannerStackId}, dyeStackId=${currentDyeStackId}`); + sendRequest(bot, requestId, actionList); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Loom pattern application failed'); + } + + // Put result away from cursor + await bot.putAway(0); + }, + + async takeResult(): Promise { + // Result is already in cursor after applyPattern, just put away + await bot.putAway(0); + return null; + }, + + bannerItem(): Item | null { + return window.slots[LoomSlots.BANNER] || null; + }, + + dyeItem(): Item | null { + return window.slots[LoomSlots.DYE] || null; + }, + + close() { + bot.closeWindow(window); + }, + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/smithing.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/smithing.mts new file mode 100644 index 0000000..6e3dc36 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/smithing.mts @@ -0,0 +1,253 @@ +/** + * Smithing Table Workstation - Upgrade operations for Bedrock protocol + * + * Container IDs from packet captures: + * - smithing_table_template: slot 53 + * - smithing_table_input: slot 51 + * - smithing_table_material: slot 52 + */ + +import type { Item as ItemType } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../../index.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds } from '../item-stack-actions.mts'; +import { findItemInAllSlots, CraftingSlots } from '../crafting-core.mts'; +import PItem from 'prismarine-item'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const SmithingSlots = { + TEMPLATE: 53, + INPUT: 51, + MATERIAL: 52, +} as const; + +// ============================================================================ +// Smithing Table Interface +// ============================================================================ + +/** Result item info for smithing upgrade */ +export interface SmithingResult { + network_id: number; + count?: number; + metadata?: number; + block_runtime_id?: number; + extra?: { has_nbt: number; can_place_on: any[]; can_destroy: any[] }; +} + +export interface SmithingTable { + window: Window; + /** Upgrade item (e.g., diamond to netherite) - requires recipe network ID and result item info */ + upgrade: (recipeNetworkId: number, result: SmithingResult) => Promise; + /** Put template in template slot */ + putTemplate: (itemType: number | string, metadata: number | null) => Promise; + /** Put item to upgrade */ + putInput: (itemType: number | string, metadata: number | null) => Promise; + /** Put upgrade material */ + putMaterial: (itemType: number | string, metadata: number | null) => Promise; + /** Close the smithing table */ + close: () => void; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Open a smithing table and return interface for upgrades + */ +export async function openSmithingTable(bot: BedrockBot, smithingBlock: Block): Promise { + const window = await bot.openBlock(smithingBlock); + bot.logger.debug(`Opened smithing table window: ${window?.id}`); + + // Track stack IDs of items placed in smithing slots + let templateStackId = 0; + let inputStackId = 0; + let materialStackId = 0; + + return { + window, + + async upgrade(recipeNetworkId: number, result: SmithingResult): Promise { + // Get stack IDs from window slots (updated after putTemplate/putInput/putMaterial) + const templateItem = window.slots[SmithingSlots.TEMPLATE]; + const inputItem = window.slots[SmithingSlots.INPUT]; + const materialItem = window.slots[SmithingSlots.MATERIAL]; + + const tStackId = templateItem ? getStackId(templateItem) : templateStackId; + const iStackId = inputItem ? getStackId(inputItem) : inputStackId; + const mStackId = materialItem ? getStackId(materialItem) : materialStackId; + + const requestId = getNextItemStackRequestId(); + + // Build result_items for results_deprecated (required by protocol) + const resultItems = [{ + network_id: result.network_id, + count: result.count ?? 1, + metadata: result.metadata ?? 0, + block_runtime_id: result.block_runtime_id ?? 0, + extra: result.extra ?? { has_nbt: 0, can_place_on: [], can_destroy: [] }, + }]; + + // Find an empty slot for output + const emptySlot = bot.inventory.slots.findIndex( + (s, i) => i >= bot.inventory.inventoryStart && i <= bot.inventory.inventoryEnd && !s + ); + const destSlot = emptySlot !== -1 ? emptySlot : 0; + + // Based on packet capture: craft_recipe + results_deprecated + consume (all 3) + place + // Use 'place' directly to inventory like stonecutter (not 'take' to cursor) + const actionList: any[] = [ + { + type_id: 'craft_recipe', + recipe_network_id: recipeNetworkId, + times_crafted: 1, + }, + { + type_id: 'results_deprecated', + result_items: resultItems, + times_crafted: 1, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.SMITHING_TABLE_TEMPLATE }, + slot: SmithingSlots.TEMPLATE, + stack_id: tStackId, + }, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.SMITHING_TABLE_INPUT }, + slot: SmithingSlots.INPUT, + stack_id: iStackId, + }, + }, + { + type_id: 'consume', + count: 1, + source: { + slot_type: { container_id: ContainerIds.SMITHING_TABLE_MATERIAL }, + slot: SmithingSlots.MATERIAL, + stack_id: mStackId, + }, + }, + { + type_id: 'place', + count: 1, + source: { + slot_type: { container_id: ContainerIds.CREATIVE_OUTPUT }, + slot: CraftingSlots.CREATIVE_OUTPUT_SLOT, + stack_id: requestId, + }, + destination: { + slot_type: { container_id: ContainerIds.HOTBAR_AND_INVENTORY }, + slot: destSlot, + stack_id: 0, + }, + }, + ]; + + sendRequest(bot, requestId, actionList); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Smithing upgrade failed'); + } + + // item_stack_response only updates counts, not creates new items + // Manually create the output item in the destination slot + const Item = PItem(bot.registry); + const newItem = new Item(result.network_id, result.count ?? 1, result.metadata ?? 0); + (newItem as any).stackId = requestId; // Use request_id as stack_id + bot.inventory.updateSlot(destSlot, newItem); + }, + + async putTemplate(itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Template ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .place(1, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, { containerId: ContainerIds.SMITHING_TABLE_TEMPLATE, slot: SmithingSlots.TEMPLATE, stackId: 0 }) + .build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to put template in smithing table'); + } + + // Track the stack ID for later use in upgrade + templateStackId = stackId; + }, + + async putInput(itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Input item ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .place(1, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, { containerId: ContainerIds.SMITHING_TABLE_INPUT, slot: SmithingSlots.INPUT, stackId: 0 }) + .build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to put input in smithing table'); + } + + // Track the stack ID for later use in upgrade + inputStackId = stackId; + }, + + async putMaterial(itemType: number | string, metadata: number | null): Promise { + const foundItem = findItemInAllSlots(bot, itemType, metadata); + if (!foundItem) { + throw new Error(`Material ${itemType} not found in inventory`); + } + + const slotIndex = foundItem.slot; + const stackId = getStackId(foundItem); + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .place(1, { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: slotIndex, stackId }, { containerId: ContainerIds.SMITHING_TABLE_MATERIAL, slot: SmithingSlots.MATERIAL, stackId: 0 }) + .build() + ); + + if (!(await waitForResponse(bot, requestId))) { + throw new Error('Failed to put material in smithing table'); + } + + // Track the stack ID for later use in upgrade + materialStackId = stackId; + }, + + close() { + bot.closeWindow(window); + }, + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrock/workstations/stonecutter.mts b/bridge/lib/mineflayer/lib/bedrock/workstations/stonecutter.mts new file mode 100644 index 0000000..df38735 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrock/workstations/stonecutter.mts @@ -0,0 +1,142 @@ +/** + * Stonecutter Workstation - Stone crafting operations for Bedrock protocol + * + * Container IDs from packet captures: + * - stonecutter_input: slot 3 + */ + +import type { Item } from 'prismarine-item'; +import type { Block } from 'prismarine-block'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../../index.js'; +import { actions, getNextItemStackRequestId, getStackId, sendRequest, waitForResponse, ContainerIds } from '../item-stack-actions.mts'; +import { CraftingSlots } from '../crafting-core.mts'; +import { twoStepTransfer } from '../container.mts'; + +// ============================================================================ +// Constants +// ============================================================================ + +export const StonecutterSlots = { + INPUT: 3, +} as const; + +// ============================================================================ +// Stonecutter Interface +// ============================================================================ + +export interface Stonecutter { + window: Window; + /** Craft using stonecutter. recipeNetworkId is the recipe's network_id */ + craft: (recipeNetworkId: number, count?: number) => Promise; + /** Close the stonecutter */ + close: () => void; +} + +// ============================================================================ +// Implementation +// ============================================================================ + +/** + * Open a stonecutter block and return interface for crafting + */ +export async function openStonecutter(bot: BedrockBot, stonecutterBlock: Block): Promise { + const window = await bot.openBlock(stonecutterBlock); + bot.logger.debug(`Opened stonecutter window: ${window?.id}`); + + // Track the stack ID of items placed in stonecutter input + let inputStackId = 0; + + return { + window, + + async craft(recipeNetworkId: number, count: number = 1): Promise { + // Find stone-type item in inventory for initial placement + const inputSlot = bot.inventory.slots.findIndex((s) => s && s.name?.includes('stone')); + if (inputSlot === -1) { + throw new Error('No stone item found in inventory for stonecutter'); + } + + const inputItem = bot.inventory.slots[inputSlot]!; + const sourceStackId = getStackId(inputItem); + + // Transfer item to stonecutter using two-step cursor transfer + const result = await twoStepTransfer( + bot, + { containerId: ContainerIds.HOTBAR_AND_INVENTORY, slot: inputSlot, stackId: sourceStackId }, + { containerId: ContainerIds.STONECUTTER_INPUT, slot: StonecutterSlots.INPUT, stackId: 0 }, + count + ); + + if (!result.success) { + throw new Error('Failed to place item in stonecutter'); + } + + // The stackId of items in stonecutter is tracked from the cursor transfer + inputStackId = result.cursorStackId; + + // Find an empty slot for output + const emptySlot = bot.inventory.slots.findIndex((s, i) => + i >= bot.inventory.inventoryStart && i <= bot.inventory.inventoryEnd && !s + ); + const destSlot = emptySlot !== -1 ? emptySlot : 0; + + // Need to wait a bit for server to register the placed item + await new Promise((r) => setTimeout(r, 200)); + + // Craft the item - use craft_recipe + consume + place pattern from packet captures + const craftRequestId = getNextItemStackRequestId(); + + // Debug: log window slots + bot.logger.info(`Stonecutter window slots: ${JSON.stringify(window.slots.map((s) => s ? { name: s.name, count: s.count, stackId: getStackId(s) } : null))}`); + + // Get the updated stackId from the window slot (it may have been updated by the server) + const stonecutterInputItem = window.slots.find((s) => s != null); + const currentInputStackId = stonecutterInputItem ? getStackId(stonecutterInputItem) : inputStackId; + + bot.logger.info(`Stonecutter craft: recipeId=${recipeNetworkId}, inputStackId=${currentInputStackId} (tracked: ${inputStackId}), destSlot=${destSlot}`); + + const actionList: any[] = [ + { + type_id: 'craft_recipe', + recipe_network_id: recipeNetworkId, + times_crafted: count, + }, + { + type_id: 'consume', + count: count, + source: { + slot_type: { container_id: ContainerIds.STONECUTTER_INPUT }, + slot: StonecutterSlots.INPUT, + stack_id: currentInputStackId, + }, + }, + { + type_id: 'place', + count: count, // Stonecutter typically outputs 1:1 or 2:1 + source: { + slot_type: { container_id: ContainerIds.CREATIVE_OUTPUT }, + slot: CraftingSlots.CREATIVE_OUTPUT_SLOT, + stack_id: craftRequestId, // Use negative request ID as stack ID for crafted items + }, + destination: { + slot_type: { container_id: ContainerIds.HOTBAR_AND_INVENTORY }, + slot: destSlot, + stack_id: 0, + }, + }, + ]; + + bot.logger.debug(`Stonecutter actions: ${JSON.stringify(actionList)}`); + sendRequest(bot, craftRequestId, actionList); + + if (!(await waitForResponse(bot, craftRequestId))) { + throw new Error('Stonecutter craft failed'); + } + }, + + close() { + bot.closeWindow(window); + }, + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/attribute-patch.js b/bridge/lib/mineflayer/lib/bedrockPlugins/attribute-patch.js new file mode 100644 index 0000000..5211a7f --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/attribute-patch.js @@ -0,0 +1,30 @@ +// Patch prismarine-physics to handle missing bedrock attributes +const origPhysics = require('prismarine-physics'); +const { Physics: OrigPhysics, PlayerState } = origPhysics; + +function PatchedPhysics(mcData, world) { + // If attributesByName is missing, provide defaults + if (!mcData.attributesByName || !mcData.attributesByName.movementSpeed) { + const attrs = [ + { name: 'movementSpeed', resource: 'minecraft:movement', min: 0, max: 3.4028235E38, default: 0.1 }, + { name: 'followRange', resource: 'minecraft:follow_range', min: 0, max: 2048, default: 16 }, + { name: 'knockbackResistance', resource: 'minecraft:knockback_resistance', min: 0, max: 1, default: 0 }, + { name: 'attackDamage', resource: 'minecraft:attack_damage', min: 0, max: 3.4028235E38, default: 1 }, + { name: 'armor', resource: 'minecraft:armor', min: 0, max: 30, default: 0 }, + { name: 'armorToughness', resource: 'minecraft:armor_toughness', min: 0, max: 20, default: 0 }, + { name: 'attackSpeed', resource: 'minecraft:attack_speed', min: 0, max: 1024, default: 4 }, + { name: 'luck', resource: 'minecraft:luck', min: -1024, max: 1024, default: 0 }, + { name: 'maxHealth', resource: 'minecraft:health', min: 0, max: 1024, default: 20 }, + ]; + mcData.attributesArray = attrs; + mcData.attributes = {}; + mcData.attributesByName = {}; + for (const attr of attrs) { + mcData.attributes[attr.resource] = attr; + mcData.attributesByName[attr.name] = attr; + } + } + return OrigPhysics(mcData, world); +} + +module.exports = { Physics: PatchedPhysics, PlayerState }; diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/bed.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/bed.mts new file mode 100644 index 0000000..c59e6dd --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/bed.mts @@ -0,0 +1,229 @@ +import type { Bot } from '../..' +import type { Block } from 'prismarine-block' +import { Vec3 } from 'vec3' + +export default function inject(bot: Bot) { + bot.isSleeping = false + + // All bed block names in Bedrock Edition + const beds = new Set([ + 'white_bed', 'orange_bed', 'magenta_bed', 'light_blue_bed', + 'yellow_bed', 'lime_bed', 'pink_bed', 'gray_bed', + 'light_gray_bed', 'cyan_bed', 'purple_bed', 'blue_bed', + 'brown_bed', 'green_bed', 'red_bed', 'black_bed', 'bed' + ]) + + // Bedrock direction: 0=south, 1=west, 2=north, 3=east + // Maps to offset from foot to head + const DIRECTION_OFFSETS: Record = { + 0: new Vec3(0, 0, 1), // south (+Z) + 1: new Vec3(-1, 0, 0), // west (-X) + 2: new Vec3(0, 0, -1), // north (-Z) + 3: new Vec3(1, 0, 0), // east (+X) + } + + function isABed(block: Block): boolean { + return beds.has(block.name) + } + + interface BedMetadata { + part: boolean // true: head, false: foot + occupied: boolean + facing: number // 0: south, 1: west, 2: north, 3: east + headOffset: Vec3 // offset from foot to head + } + + function parseBedMetadata(bedBlock: Block): BedMetadata { + const metadata: BedMetadata = { + part: false, + occupied: false, + facing: 0, + headOffset: new Vec3(0, 0, 1) + } + + // Bedrock uses _properties for block state + const props = (bedBlock as any)._properties + if (props) { + // direction: 0=south, 1=west, 2=north, 3=east + metadata.facing = props.direction ?? props['minecraft:direction'] ?? 0 + // head_piece_bit: true for head, false for foot + metadata.part = props.head_piece_bit === true || props.head_piece_bit === 1 + // occupied_bit: true if someone is in the bed + metadata.occupied = props.occupied_bit === true || props.occupied_bit === 1 + // Calculate head offset based on direction + metadata.headOffset = DIRECTION_OFFSETS[metadata.facing] || new Vec3(0, 0, 1) + } + + return metadata + } + + async function wake(): Promise { + if (!bot.isSleeping) { + throw new Error('already awake') + } + + // Bedrock uses player_action packet with stop_sleeping action + bot._client.write('player_action', { + runtime_entity_id: bot.entity.id, + action: 'stop_sleeping', + position: { x: 0, y: 0, z: 0 }, + result_position: { x: 0, y: 0, z: 0 }, + face: 0 + }) + } + + async function sleep(bedBlock: Block): Promise { + const thunderstorm = bot.isRaining && (bot.thunderState > 0) + if (!thunderstorm && !(bot.time.timeOfDay >= 12541 && bot.time.timeOfDay <= 23458)) { + throw new Error("it's not night and it's not a thunderstorm") + } + if (bot.isSleeping) { + throw new Error('already sleeping') + } + if (!isABed(bedBlock)) { + throw new Error('wrong block : not a bed block') + } + + const botPos = bot.entity.position.floored() + const metadata = parseBedMetadata(bedBlock) + let headPoint = bedBlock.position + + if (metadata.occupied) { + throw new Error('the bed is occupied') + } + + if (!metadata.part) { + // This is the foot part, find the head + const upperBlock = bot.blockAt(bedBlock.position.plus(metadata.headOffset)) + + if (upperBlock && isABed(upperBlock)) { + headPoint = upperBlock.position + } else { + // Try the opposite direction + const lowerBlock = bot.blockAt(bedBlock.position.plus(metadata.headOffset.scaled(-1))) + + if (lowerBlock && isABed(lowerBlock)) { + // If there are 2 foot parts, minecraft only lets you sleep if you click on the lower one + headPoint = bedBlock.position + bedBlock = lowerBlock + } else { + throw new Error("there's only half bed") + } + } + } + + if (!bot.canDigBlock(bedBlock)) { + throw new Error('cant click the bed') + } + + // Check distance constraints + const clickRange = [2, -3, -3, 2] // [south, west, north, east] + const monsterRange = [7, -8, -8, 7] + const oppositeCardinal = (metadata.facing + 2) % 4 + + if (clickRange[oppositeCardinal] < 0) { + clickRange[oppositeCardinal]-- + } else { + clickRange[oppositeCardinal]++ + } + + const nwClickCorner = headPoint.offset(clickRange[1], -2, clickRange[2]) + const seClickCorner = headPoint.offset(clickRange[3], 2, clickRange[0]) + if ( + botPos.x > seClickCorner.x || botPos.x < nwClickCorner.x || + botPos.y > seClickCorner.y || botPos.y < nwClickCorner.y || + botPos.z > seClickCorner.z || botPos.z < nwClickCorner.z + ) { + throw new Error('the bed is too far') + } + + // Check for monsters nearby (unless creative mode) + if (bot.game.gameMode !== 'creative') { + const nwMonsterCorner = headPoint.offset(monsterRange[1], -6, monsterRange[2]) + const seMonsterCorner = headPoint.offset(monsterRange[3], 4, monsterRange[0]) + + for (const key of Object.keys(bot.entities)) { + const entity = bot.entities[key] + if (entity.kind === 'Hostile mobs') { + const entityPos = entity.position.floored() + if ( + entityPos.x <= seMonsterCorner.x && entityPos.x >= nwMonsterCorner.x && + entityPos.y <= seMonsterCorner.y && entityPos.y >= nwMonsterCorner.y && + entityPos.z <= seMonsterCorner.z && entityPos.z >= nwMonsterCorner.z + ) { + throw new Error('there are monsters nearby') + } + } + } + } + + // Register listener before activating to avoid race conditions + const waitingPromise = waitUntilSleep() + await bot.activateBlock(bedBlock) + await waitingPromise + } + + async function waitUntilSleep(): Promise { + return new Promise((resolve, reject) => { + const timeoutForSleep = setTimeout(() => { + reject(new Error('bot is not sleeping')) + }, 3000) + + bot.once('sleep', () => { + clearTimeout(timeoutForSleep) + resolve() + }) + }) + } + + // Handle animate packet for wake_up action + bot._client.on('animate', (packet: { action: string; runtime_entity_id?: any; entity_id?: any }) => { + if (packet.action === 'wake_up') { + const entityId = packet.runtime_entity_id ?? packet.entity_id + if (entityId === bot.entity.id) { + bot.isSleeping = false + bot.emit('wake') + } else { + // Another entity woke up + const entity = bot.entities[entityId] + if (entity) { + bot.emit('entityWake', entity) + } + } + } + }) + + // Handle player_action for sleep detection + // Note: The client sends start_sleeping after server confirms bed interaction + // We track this via the server's response (occupied bit change or move to bed position) + bot._client.on('player_action', (packet: { action: string; runtime_entity_id?: any }) => { + if (packet.action === 'start_sleeping' && packet.runtime_entity_id === bot.entity.id) { + bot.isSleeping = true + bot.emit('sleep') + } + }) + + // Handle set_spawn_position for spawnReset event + // This is emitted when player can't spawn at their bed (bed destroyed, obstructed) + // In Bedrock, we track this via set_spawn_position with specific values + bot._client.on('set_spawn_position', (packet: { + spawn_type: string + player_position?: { x: number; y: number; z: number } + world_position?: { x: number; y: number; z: number } + }) => { + // When spawn type is 'player' and position is invalid (very large negative values), + // it indicates spawn reset + if (packet.spawn_type === 'player') { + const pos = packet.player_position + if (pos && (pos.x === -2147483648 || pos.y === -2147483648 || pos.z === -2147483648)) { + bot.emit('spawnReset') + } + } + }) + + // Expose API + bot.parseBedMetadata = parseBedMetadata + bot.wake = wake + bot.sleep = sleep + bot.isABed = isABed +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/block_actions.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/block_actions.mts new file mode 100644 index 0000000..0d4589b --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/block_actions.mts @@ -0,0 +1,128 @@ +import type { BedrockBot } from '../../index.js'; +import { Vec3 } from 'vec3'; + +const CARDINALS = { + north: new Vec3(0, 0, -1), + south: new Vec3(0, 0, 1), + west: new Vec3(-1, 0, 0), + east: new Vec3(1, 0, 0), +}; + +const FACING_MAP: Record> = { + north: { west: 'right', east: 'left' }, + south: { west: 'left', east: 'right' }, + west: { north: 'left', south: 'right' }, + east: { north: 'right', south: 'left' }, +}; + +export default function inject(bot: BedrockBot) { + const { instruments, blocks } = bot.registry; + + // Stores how many players have currently open a container at a certain position + const openCountByPos: Record = {}; + + function parseChestMetadata(chestBlock: any) { + // Bedrock uses _properties with minecraft:cardinal_direction + // Java uses metadata with encoded facing/type/waterlogged + if (chestBlock._properties) { + // Bedrock Edition - get facing from block properties + const facing = chestBlock._properties['minecraft:cardinal_direction'] as string; + return { facing }; + } else if (bot.registry.blockStates && chestBlock.stateId !== undefined) { + // Alternative: use registry blockStates + const states = bot.registry.blockStates[chestBlock.stateId]?.states; + const facing = states?.['minecraft:cardinal_direction']?.value as string; + return { facing }; + } else { + // Fallback for Java Edition + const chestTypes = ['single', 'right', 'left']; + return bot.supportFeature('doesntHaveChestType') + ? { facing: Object.keys(CARDINALS)[chestBlock.metadata - 2] } + : { + waterlogged: !(chestBlock.metadata & 1), + type: chestTypes[(chestBlock.metadata >> 1) % 3], + facing: Object.keys(CARDINALS)[Math.floor(chestBlock.metadata / 6)], + }; + } + } + + function getChestType(chestBlock: any) { + // Returns 'single', 'right' or 'left' + // if (bot.supportFeature('doesntHaveChestType')) { + const facing = parseChestMetadata(chestBlock).facing; + + if (!facing) return 'single'; + + // We have to check if the adjacent blocks in the perpendicular cardinals are the same type + const perpendicularCardinals = Object.keys(FACING_MAP[facing]); + for (const cardinal of perpendicularCardinals) { + const cardinalOffset = CARDINALS[cardinal as keyof typeof CARDINALS]; + if (bot.blockAt(chestBlock.position.plus(cardinalOffset))?.type === chestBlock.type) { + return FACING_MAP[cardinal][facing]; + } + } + + return 'single'; + // } else { + // return parseChestMetadata(chestBlock).type + // } + } + + bot._client.on('block_event', (packet) => { + const pt = new Vec3(packet.position.x, packet.position.y, packet.position.z); + const block = bot.blockAt(pt); + + // Ignore on non-vanilla blocks + if (block === null) { + return; + } // !blocks[block.type] non vanilla <--- + + const blockName = block.name; + + if (blockName === 'noteblock') { + // Pre 1.13 + bot.emit('noteHeard', block, instruments[packet.data], packet.data); + } else if (blockName === 'note_block') { + // 1.13 onward + bot.emit('noteHeard', block, instruments[Math.floor(block.metadata / 50)], Math.floor((block.metadata % 50) / 2)); + } else if (blockName === 'sticky_piston' || blockName === 'piston') { + bot.emit('pistonMove', block, packet.data, packet.data); // find java values!!! + } else { + let block2 = null; + + if (blockName === 'chest' || blockName === 'trapped_chest') { + const chestType = getChestType(block); + if (chestType === 'right') { + const index = Object.values(FACING_MAP[parseChestMetadata(block).facing!]).indexOf('left'); + const cardinalBlock2 = Object.keys(FACING_MAP[parseChestMetadata(block).facing!])[index]; + const block2Position = block.position.plus(CARDINALS[cardinalBlock2 as keyof typeof CARDINALS]); + block2 = bot.blockAt(block2Position); + } else if (chestType === 'left') return; // Omit left part of the chest so 'chestLidMove' doesn't emit twice when it's a double chest + } + + // Emit 'chestLidMove' only if the number of players with the lid open changes + if (openCountByPos[block.position.toString()] !== packet.data) { + bot.emit('chestLidMove', block, packet.data === 1, block2); + if (packet.data > 0) { + openCountByPos[block.position.toString()] = packet.data; + } else { + delete openCountByPos[block.position.toString()]; + } + } + } + }); + + bot._client.on('level_event', (packet) => { + if (packet.event === 'block_start_break' || packet.event === 'block_stop_break') { + const destroyStage = 0; //packet.destroyStage unavalible for bedrock, calculates client-side + const pt = new Vec3(packet.position.x, packet.position.y, packet.position.z); + const block = bot.blockAt(pt); + const entity = null; //bot.entities[packet.entityId] + if (packet.event === 'block_stop_break') { + bot.emit('blockBreakProgressEnd', block, entity); + } else { + bot.emit('blockBreakProgressObserved', block, destroyStage, entity); + } + } + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/blocks.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/blocks.mts new file mode 100644 index 0000000..51a3d67 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/blocks.mts @@ -0,0 +1,715 @@ +import type { BedrockBot } from '../../index.js'; +import { Vec3 } from 'vec3'; +import assert from 'assert'; +import { onceWithCleanup } from '../promise_utils.js'; +import fs from 'fs'; +import { createRequire } from 'module'; +import type { BedrockChunk } from 'prismarine-chunk'; +const require = createRequire(import.meta.url); + +const { BlobEntry, BlobType } = require('prismarine-chunk'); +const BlobStore = require('../BlobStore'); + +const { OctahedronIterator } = require('prismarine-world/src/iterators'); + +const serialize = (obj: any) => JSON.stringify(obj, (k, v) => (typeof v?.valueOf?.() === 'bigint' ? v.toString() : v)); + +const dimensionNames: Record = { + '-1': 'minecraft:nether', + 0: 'minecraft:overworld', + 1: 'minecraft:end', +}; + +interface BlocksOptions { + version?: string; + storageBuilder?: any; + hideErrors?: boolean; +} + +export default function inject(bot: BedrockBot, { version, storageBuilder, hideErrors }: BlocksOptions = {}) { + // const registry = bot._client.host !== 'mco.cubecraft.net' ? bot.registry : require('prismarine-registry')('bedrock_1.18.30') + const Block = require('prismarine-block')(bot.registry); + const Chunk = require('prismarine-chunk')(bot.registry); // bot.registry ChunkColumn bot.registry + const World = require('prismarine-world')(bot.registry); + const blobStore = new BlobStore(); + + function delColumn(chunkX: number, chunkZ: number) { + bot.world.unloadColumn(chunkX, chunkZ); + } + // load chunk into a column + function addColumn(args: any) { + try { + bot.world.setColumn(args.x, args.z, args.column); + } catch (e) { + bot.emit('error', e); + } + } + + async function waitForChunksToLoad() { + const dist = 4; + // This makes sure that the bot's real position has been already sent + if (!bot.entity.height) await onceWithCleanup(bot, 'chunkColumnLoad'); + const pos = bot.entity.position; + const center = new Vec3((pos.x >> 4) << 4, 0, (pos.z >> 4) << 4); + // get corner coords of 5x5 chunks around us + const chunkPosToCheck = new Set(); + for (let x = -dist; x <= dist; x++) { + for (let y = -dist; y <= dist; y++) { + // ignore any chunks which are already loaded + const pos = center.plus(new Vec3(x, 0, y).scaled(16)); + if (!bot.world.getColumnAt(pos)) chunkPosToCheck.add(pos.toString()); + } + } + + if (chunkPosToCheck.size) { + return new Promise((resolve) => { + function waitForLoadEvents(columnCorner: Vec3) { + chunkPosToCheck.delete(columnCorner.toString()); + if (chunkPosToCheck.size === 0) { + // no chunks left to find + bot.world.off('chunkColumnLoad', waitForLoadEvents); // remove this listener instance + resolve(); + } + } + + // begin listening for remaining chunks to load + bot.world.on('chunkColumnLoad', waitForLoadEvents); + }); + } + } + + bot._client.on('join', () => { + bot._client.queue('client_cache_status', { enabled: cachingEnabled }); + }); + + // this would go in pworld + let subChunkMissHashes: any[] = []; + let sentMiss = false; + let gotMiss = false; + let lostSubChunks = 0, + foundSubChunks = 0; + + const cachingEnabled = false; + + //let points = [] + async function processLevelChunk(packet: any) { + const cc = new Chunk({ x: packet.x, z: packet.z }); + if (!cachingEnabled) { + await cc.networkDecodeNoCache(packet.payload, packet.sub_chunk_count); + } else if (cachingEnabled) { + const misses = await cc.networkDecode(packet.blobs.hashes, blobStore, packet.payload); + if (!packet.blobs.hashes.length) return; // no blobs + + bot._client.queue('client_cache_blob_status', { + misses: misses.length, + haves: 0, + have: [], + missing: misses, + }); + + if (packet.sub_chunk_count < 0) { + // 1.18+ + for (const miss of misses) blobStore.addPending(miss, new BlobEntry({ type: BlobType.Biomes, x: packet.x, z: packet.z })); + } else { + // 1.17- + const lastBlob = packet.blobs.hashes[packet.blobs.hashes.length - 1]; + for (const miss of misses) { + blobStore.addPending( + miss, + new BlobEntry({ + type: miss === lastBlob ? BlobType.Biomes : BlobType.ChunkSection, + x: packet.x, + z: packet.z, + }) + ); + } + sentMiss = true; + } + + blobStore.once(misses, async () => { + // The things we were missing have now arrived + const now = await cc.networkDecode(packet.blobs.hashes, blobStore, packet.payload); + fs.writeFileSync( + `fixtures/${version}/level_chunk CacheMissResponse ${packet.x},${packet.z}.json`, + serialize({ + blobs: Object.fromEntries(packet.blobs.hashes.map((h: any) => [h.toString(), blobStore.get(h).buffer])), + }) + ); + assert.strictEqual(now.length, 0); + + bot._client.queue('client_cache_blob_status', { + misses: 0, + haves: packet.blobs.hashes.length, + have: packet.blobs.hashes, + missing: [], + }); + + gotMiss = true; + }); + } + + if (packet.sub_chunk_count < 0) { + // 1.18.0+ + // 1.18+ handling, we need to send a SubChunk request + const maxSubChunkCount = packet.highest_subchunk_count || 5; // field is set if sub_chunk_count=-2 (1.18.10+) meaning all air + + function getChunkCoordinates(pos: Vec3) { + let chunkX = Math.floor(pos.x / 16); + let chunkZ = Math.floor(pos.z / 16); + let subchunkY = Math.floor(pos.y / 16); + return { chunkX: chunkX, chunkZ: chunkZ, subchunkY: subchunkY }; + } + + if (bot.registry.version['>=']('1.18.11')) { + // We can send the request in one big load! + // let origin = getChunkCoordinates(bot.entity.position) + // let x = packet.x <= 0 ? 255 + packet.x : packet.x + // let z = packet.z <= 0 ? 255 + packet.z : packet.z + + let requests: any[] = []; + + let offset = cc.minCY; + // load all height of the chunk + for (let i = offset; i <= offset + maxSubChunkCount; i++) { + requests.push({ dx: 0, dz: 0, dy: i }); + } + if (requests.length > 0) { + bot._client.queue('subchunk_request', { + origin: { x: packet.x, z: packet.z, y: 0 }, + requests, + dimension: 0, + }); + } + } else if (bot.registry.version['>=']('1.18')) { + for (let i = 1; i < maxSubChunkCount; i++) { + // Math.min(maxSubChunkCount, 5) + bot._client.queue('subchunk_request', { + x: packet.x, + z: packet.z, + y: 0, + dimension: 0, + } as any); + } + } + } + + addColumn({ + x: packet.x, + z: packet.z, + column: cc, + }); + } + + async function loadCached(cc: any, x: number, y: number, z: number, blobId: any, extraData: any) { + const misses = await cc.networkDecodeSubChunk([blobId], blobStore, extraData); + subChunkMissHashes.push(...misses); + + for (const miss of misses) { + blobStore.addPending(miss, new BlobEntry({ type: BlobType.ChunkSection, x, z, y })); + } + + if (subChunkMissHashes.length >= 10) { + sentMiss = true; + const r = { + misses: subChunkMissHashes.length, + haves: 0, + have: [], + missing: subChunkMissHashes, + }; + + bot._client.queue('client_cache_blob_status', r); + subChunkMissHashes = []; + } + + if (misses.length) { + const [missed] = misses; + // Once we get this blob, try again + + blobStore.once([missed], async () => { + gotMiss = true; + fs.writeFileSync( + `fixtures/${version}/subchunk CacheMissResponse ${x},${z},${y}.json`, + serialize({ + blobs: Object.fromEntries([[missed.toString(), blobStore.get(missed).buffer]]), + }) + ); + // Call this again, ignore the payload since that's already been decoded + const misses = await cc.networkDecodeSubChunk([missed], blobStore); + assert(!misses.length, 'Should not have missed anything'); + }); + } + } + + async function processSubChunk(packet: protocolTypes.packet_subchunk) { + const pkt = packet as any; + if (pkt.entries) { + // 1.18.10+ handling + for (const entry of pkt.entries) { + const x = pkt.origin.x + entry.dx; + const y = pkt.origin.y + Buffer.from([entry.dy]).readInt8(0); + const z = pkt.origin.z + entry.dz; + + const cc = bot.world.getColumn(x, z) as BedrockChunk; + + if (entry.result === 'success') { + foundSubChunks++; + if (pkt.cache_enabled) { + await loadCached(cc, x, y, z, entry.blob_id, entry.payload); + } else { + try { + await cc.networkDecodeSubChunkNoCache(y, entry.payload); + bot.world.emit('chunkColumnLoad', new Vec3(x, y, z)); + } catch (e) { + bot.logger.error(e); + } + } + } else { + lostSubChunks++; + } + } + } else { + if (pkt.request_result !== 'success') { + lostSubChunks++; + return; + } + foundSubChunks++; + const cc = bot.world.getColumn(pkt.x, pkt.z) as BedrockChunk; + if (pkt.cache_enabled) { + await loadCached(cc, pkt.x, pkt.y, pkt.z, pkt.blob_id, pkt.data); + } else { + await cc.networkDecodeSubChunkNoCache(pkt.y, pkt.data); + } + } + } + + async function processCacheMiss(packet: any) { + const acks: any[] = []; + for (const { hash, payload } of packet.blobs) { + const name = hash.toString(); + blobStore.updatePending(name, { buffer: payload }); + acks.push(hash); + } + + // Send back an ACK + bot._client.queue('client_cache_blob_status', { + misses: 0, + haves: acks.length, + have: [], + missing: acks, + }); + } + + bot._client.on('level_chunk', processLevelChunk); + bot._client.on('subchunk', (sc) => processSubChunk(sc).catch(console.error)); + bot._client.on('client_cache_miss_response', processCacheMiss); + + // fs.mkdirSync(`fixtures/${version}/pchunk`, { recursive: true }) + // bot._client.on('packet', ({ data: { name, params }, fullBuffer }) => { + // if (name === 'level_chunk') { + // fs.writeFileSync(`fixtures/${version}/level_chunk ${cachingEnabled ? 'cached' : ''} ${params.x},${params.z}.json`, serialize(params)) + // } else if (name === 'subchunk') { + // if (params.origin) { + // fs.writeFileSync(`fixtures/${version}/subchunk ${cachingEnabled ? 'cached' : ''} ${params.origin.x},${params.origin.z},${params.origin.y}.json`, serialize(params)) + // } else { + // fs.writeFileSync(`fixtures/${version}/subchunk ${cachingEnabled ? 'cached' : ''} ${params.x},${params.z},${params.y}.json`, serialize(params)) + // } + // } + // }) + + function getMatchingFunction(matching: any) { + if (typeof matching !== 'function') { + if (!Array.isArray(matching)) { + matching = [matching]; + } + return isMatchingType; + } + return matching; + + function isMatchingType(block: any) { + return block === null ? false : matching.indexOf(block.type) >= 0; + } + } + + function isBlockInSection(section: any, matcher: any) { + if (!section) return false; // section is empty, skip it (yay!) + // If the chunk use a palette we can speed up the search by first + // checking the palette which usually contains less than 20 ids + // vs checking the 4096 block of the section. If we don't have a + // match in the palette, we can skip this section. + if (section.palette) { + for (const stateId of section.palette[0]) { + if (matcher(Block.fromStateId(stateId.stateId, 0))) { + return true; // the block is in the palette + } + } + return false; // skip + } + return true; // global palette, the block might be in there + } + + function getFullMatchingFunction(matcher: any, useExtraInfo: any) { + if (typeof useExtraInfo === 'boolean') { + return fullSearchMatcher; + } + + return nonFullSearchMatcher; + + function nonFullSearchMatcher(point: Vec3) { + const block = blockAt(point, true); + return matcher(block) && useExtraInfo(block); + } + + function fullSearchMatcher(point: Vec3) { + return matcher(bot.blockAt(point, useExtraInfo)); + } + } + + bot.findBlocks = (options: any) => { + const matcher = getMatchingFunction(options.matching); + const point = (options.point || bot.entity.position).floored(); + const maxDistance = options.maxDistance || 16; + const count = options.count || 1; + const useExtraInfo = options.useExtraInfo || false; + const fullMatcher = getFullMatchingFunction(matcher, useExtraInfo); + const start = new Vec3(Math.floor(point.x / 16), Math.floor(point.y / 16), Math.floor(point.z / 16)); + const it = new OctahedronIterator(start, Math.ceil((maxDistance + 8) / 16)); + // the octahedron iterator can sometime go through the same section again + // we use a set to keep track of visited sections + const visitedSections = new Set(); + + let blocks: Vec3[] = []; + let startedLayer = 0; + let next = start; + while (next) { + const column = bot.world.getColumn(next.x, next.z) as any; + const sectionY = next.y + Math.abs(bot.game.minY >> 4); + const totalSections = bot.game.height >> 4; + if (sectionY >= 0 && sectionY < totalSections && column && !visitedSections.has(next.toString())) { + const section = column.sections[sectionY]; + if (useExtraInfo === true || isBlockInSection(section, matcher)) { + const begin = new Vec3(next.x * 16, sectionY * 16 + bot.game.minY, next.z * 16); + const cursor = begin.clone(); + const end = cursor.offset(16, 16, 16); + for (cursor.x = begin.x; cursor.x < end.x; cursor.x++) { + for (cursor.y = begin.y; cursor.y < end.y; cursor.y++) { + for (cursor.z = begin.z; cursor.z < end.z; cursor.z++) { + if (fullMatcher(cursor) && cursor.distanceTo(point) <= maxDistance) blocks.push(cursor.clone()); + } + } + } + } + visitedSections.add(next.toString()); + } + // If we started a layer, we have to finish it otherwise we might miss closer blocks + if (startedLayer !== it.apothem && blocks.length >= count) { + break; + } + startedLayer = it.apothem; + next = it.next(); + } + blocks.sort((a, b) => { + return a.distanceTo(point) - b.distanceTo(point); + }); + // We found more blocks than needed, shorten the array to not confuse people + if (blocks.length > count) { + blocks = blocks.slice(0, count); + } + return blocks; + }; + + function findBlock(options: any) { + const blocks = bot.findBlocks(options); + if (blocks.length === 0) return null; + return bot.blockAt(blocks[0]); + } + + function blockAt(absolutePoint: Vec3, extraInfos = true) { + const block = bot.world.getBlock(absolutePoint); + // null block means chunk not loaded + if (!block) return null; + + return block; + } + + // if passed in block is within line of sight to the bot, returns true + // also works on anything with a position value + function canSeeBlock(block: any) { + const headPos = bot.entity.position.offset(0, bot.entity.height, 0); + const range = headPos.distanceTo(block.position); + const dir = block.position.offset(0.5, 0.5, 0.5).minus(headPos); + const match = (inputBlock: any, iter: any) => { + const intersect = iter.intersect(inputBlock.shapes, inputBlock.position); + if (intersect) { + return true; + } + return block.position.equals(inputBlock.position); + }; + const blockAtCursor = bot.world.raycast(headPos, dir.normalize(), range, match as any); + return blockAtCursor && blockAtCursor.position.equals(block.position); + } + + function updateBlockEntityData(point: Vec3, nbt: any) { + const column = bot.world.getColumnAt(point) as any; + if (!column) return; + + if (nbt) { + column.setBlockEntity(posInChunk(point), nbt); + } else { + const debug = bot.world.getBlock(point); + if (debug.entity) bot.logger.debug('Removed Entity Data'); + + column.removeBlockEntity(posInChunk(point)); + } + + const block = bot.world.getBlock(point); + bot.world.emit('blockUpdate', block, block); + } + + function posInChunk(pos: Vec3) { + return new Vec3(Math.floor(pos.x) & 15, Math.floor(pos.y), Math.floor(pos.z) & 15); + } + + function updateBlockState(point: Vec3, block_runtime_id: number) { + const registry = bot.registry as any; + if (!registry.blocksByRuntimeId) { + bot.logger.warn('registry.blocksByRuntimeId is not available'); + return; + } + let registryBlock = registry.blocksByRuntimeId[block_runtime_id]; + if (!registryBlock) registryBlock = bot.registry.blocksByName['stone']; + + // Create a new block object with stateId set to the runtime ID + // This is needed for openBlock() to send the correct block_runtime_id + const block = { + ...registryBlock, + stateId: block_runtime_id, + }; + + const oldBlock = bot.world.getBlock(point); + + if (oldBlock && oldBlock.stateId === block_runtime_id) { + bot.world.emit('blockUpdate', oldBlock, oldBlock); + return; + } + + //Rather use bot.registry.blocksByStateId[stateId]? + if (oldBlock) + if (oldBlock.type !== block.type) { + updateBlockEntityData(point, null); + } + + bot.world.setBlock(point, block); + + // Emit position-specific blockUpdate event for digging plugin + const newBlock = blockAt(point); + if (newBlock !== null) { + bot.world.emit(`blockUpdate:${point}`, oldBlock, newBlock); + } + } + + // bot._client.on('map_chunk', (packet) => { + // addColumn({ + // x: packet.x, + // z: packet.z, + // bitMap: packet.bitMap, + // heightmaps: packet.heightmaps, + // biomes: packet.biomes, + // skyLightSent: bot.game.dimension === 'minecraft:overworld', + // groundUp: packet.groundUp, + // data: packet.chunkData, + // trustEdges: packet.trustEdges, + // skyLightMask: packet.skyLightMask, + // blockLightMask: packet.blockLightMask, + // emptySkyLightMask: packet.emptySkyLightMask, + // emptyBlockLightMask: packet.emptyBlockLightMask, + // skyLight: packet.skyLight, + // blockLight: packet.blockLight + // }) + // + // if (typeof packet.blockEntities !== 'undefined') { + // const column = bot.world.getColumn(packet.x, packet.z) + // if (!column) { + // if (!hideErrors) console.warn('Ignoring block entities as chunk failed to load at', packet.x, packet.z) + // return + // } + // for (const blockEntity of packet.blockEntities) { + // if (blockEntity.x !== undefined) { // 1.17+ + // column.setBlockEntity(blockEntity, blockEntity.nbtData) + // } else { + // const pos = new Vec3(blockEntity.value.x.value & 0xf, blockEntity.value.y.value, blockEntity.value.z.value & 0xf) + // column.setBlockEntity(pos, blockEntity) + // } + // } + // } + // }) + + // bot._client.on('map_chunk_bulk', (packet) => { + // let offset = 0 + // let meta + // let i + // let size + // for (i = 0; i < packet.meta.length; ++i) { + // meta = packet.meta[i] + // size = (8192 + (packet.skyLightSent ? 2048 : 0)) * + // onesInShort(meta.bitMap) + // block ids + // 2048 * onesInShort(meta.bitMap) + // (two bytes per block id) + // 256 // biomes + // addColumn({ + // x: meta.x, + // z: meta.z, + // bitMap: meta.bitMap, + // heightmaps: packet.heightmaps, + // skyLightSent: packet.skyLightSent, + // groundUp: true, + // data: packet.data.slice(offset, offset + size) + // }) + // offset += size + // } + // + // assert.strictEqual(offset, packet.data.length) + // }) + + bot._client.on('update_subchunk_blocks', (packet) => { + // Packet Update Subchunk Blocks + // multi block change + // EXTRA NOT IMPLEMENTED (WATERLOGGED) + for (let i = 0; i < packet.blocks.length; i++) { + const record = packet.blocks[i]; + const pt = new Vec3(record.position.x, record.position.y, record.position.z); + updateBlockState(pt, record.runtime_id); + } + }); + + bot._client.on('update_block', (packet) => { + const pt = new Vec3(packet.position.x, packet.position.y, packet.position.z) as any; + pt.l = packet.layer; + updateBlockState(pt, packet.block_runtime_id); + }); + + bot._client.on('block_entity_data', (packet) => { + const pt = new Vec3(packet.position.x, packet.position.y, packet.position.z); + updateBlockEntityData(pt, packet.nbt); + }); + + // bot._client.on('explosion', (packet) => { + // // explosion + // const p = new Vec3(packet.x, packet.y, packet.z) + // packet.affectedBlockOffsets.forEach((offset) => { + // const pt = p.offset(offset.x, offset.y, offset.z) + // updateBlockState(pt, 0) + // }) + // }) // NO EXP PACKET ON BEDROCK + + // if we get a respawn packet and the dimension is changed, + // unload all chunks from memory. + let dimension: any; + let worldName: any; + function dimensionToFolderName(dimension: any) { + if (bot.supportFeature('dimensionIsAnInt')) { + return dimensionNames[dimension]; + } else if (bot.supportFeature('dimensionIsAString') || bot.supportFeature('dimensionIsAWorld')) { + return dimension; + } + } + + async function switchWorld() { + if (bot.world) { + if (storageBuilder) { + await bot.world.async.waitSaving(); + } + + for (const [name, listener] of Object.entries((bot as any)._events) as [string, any][]) { + if (name.startsWith('blockUpdate:')) { + bot.emit(name as any, null, null); + bot.off(name as any, listener as any); + } + } + + const worldAsync = bot.world.async as any; + for (const [x, z] of Object.keys(worldAsync.columns).map((key) => key.split(',').map((x) => parseInt(x, 10)))) { + bot.world.unloadColumn(x, z); + } + + if (storageBuilder) { + worldAsync.storageProvider = storageBuilder({ + version: bot.version, + worldName: dimensionToFolderName(dimension), + }); + } + } else { + bot.world = new World(null, storageBuilder ? storageBuilder({ version: bot.version, worldName: dimensionToFolderName(dimension) }) : null).sync; + startListenerProxy(); + } + } + + bot._client.on('start_game', (packet) => { + if (bot.supportFeature('dimensionIsAnInt')) { + dimension = packet.dimension; + } else { + dimension = packet.dimension; + worldName = packet.world_name; + } + switchWorld(); + }); + + bot._client.on('respawn', (packet) => { + const pkt = packet as any; + if (bot.supportFeature('dimensionIsAnInt')) { + // <=1.15.2 + if (dimension === pkt.dimension) return; + dimension = pkt.dimension; + } else { + // >= 1.15.2 + if (dimension === pkt.dimension) return; + if (worldName === pkt.world_name && pkt.copyMetadata === true) return; // don't unload chunks if in same world and metaData is true + // Metadata is true when switching dimensions however, then the world name is different packet.copyMetadata unavaliable for bedrock!!! + dimension = pkt.dimension; + worldName = pkt.world_name; + } + switchWorld(); + }); + + let listener: any; + let listenerRemove: any; + function startListenerProxy() { + if (listener) { + // custom forwarder for custom events + bot.off('newListener', listener); + bot.off('removeListener', listenerRemove); + } + // standardized forwarding + const forwardedEvents = ['blockUpdate', 'chunkColumnLoad', 'chunkColumnUnload']; + + for (const event of forwardedEvents) { + bot.world.on(event, (...args: any[]) => (bot.emit as any)(event, ...args)); + } + const blockUpdateRegex = /blockUpdate:\(-?\d+, -?\d+, -?\d+\)/; + listener = (event: string, listener: any) => { + if (blockUpdateRegex.test(event)) { + bot.world.on(event, listener); + } + }; + listenerRemove = (event: string, listener: any) => { + if (blockUpdateRegex.test(event)) { + bot.world.off(event, listener); + } + }; + bot.on('newListener', listener); + bot.on('removeListener', listenerRemove); + } + + bot.findBlock = findBlock; + bot.canSeeBlock = canSeeBlock; + bot.blockAt = blockAt; + bot._updateBlockState = updateBlockState; + bot.waitForChunksToLoad = waitForChunksToLoad; +} + +// function onesInShort (n) { +// n = n & 0xffff +// let count = 0 +// for (let i = 0; i < 16; ++i) { +// count = ((1 << i) & n) ? count + 1 : count +// } +// return count +// } diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/book.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/book.mts new file mode 100644 index 0000000..4430c02 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/book.mts @@ -0,0 +1,184 @@ +/** + * Bedrock Edition book plugin + * + * Provides book writing and signing functionality for Bedrock. + * + * Protocol differences from Java: + * - Java: Uses `edit_book` packet with NBT data + * - Bedrock: Uses `book_edit` packet with action-based editing + * - action: "replace_page" - Update page content + * - action: "add_page" - Add new page (not commonly used, replace_page auto-creates) + * - action: "delete_page" - Remove a page + * - action: "swap_pages" - Swap two pages + * - action: "sign" - Sign and finalize book (becomes written_book) + * + * Book editing flow: + * 1. Hold writable_book (book and quill) + * 2. Right-click to open (inventory_transaction item_use click_air) + * 3. C→S: book_edit {action: "replace_page", slot, page, text} + * 4. C→S: book_edit {action: "sign", slot, title, author, xuid} + * 5. Book becomes written_book (no server confirmation packet) + * + * API (matches Java): + * - bot.writeBook(slot, pages) - Write pages to book and quill + * - bot.signBook(slot, pages, author, title) - Write and sign book + */ + +import type { Bot } from '../..' +import * as assert from 'assert' + +export default function inject(bot: Bot) { + /** + * Send a book_edit packet to write page content + */ + function sendBookEdit( + slot: number, + action: string, + options: { + page?: number; + text?: string; + secondaryPage?: number; + title?: string; + author?: string; + } = {}, + ) { + const packet: Record = { + type: action, // Bedrock uses 'type' field for action + slot, + page_number: options.page ?? 0, + secondary_page_number: options.secondaryPage ?? 0, + text: options.text ?? '', + photo_name: '', + title: options.title ?? '', + author: options.author ?? '', + xuid: '', // XUID is handled by server based on client + }; + + bot._client.write('book_edit', packet); + } + + /** + * Write content to a book and quill without signing + * + * @param slot - Inventory slot containing the book (0-44) + * @param pages - Array of page contents (strings) + */ + async function writeBook(slot: number, pages: string[]): Promise { + assert.ok(slot >= 0 && slot <= 44, 'slot out of inventory range'); + + const book = bot.inventory.slots[slot]; + const writableBookId = bot.registry.itemsByName.writable_book?.id; + + assert.ok(book && writableBookId && book.type === writableBookId, `no book found in slot ${slot}`); + + const quickBarSlot = bot.quickBarSlot; + const moveToQuickBar = slot < 36; + + // Move book to hotbar if needed + if (moveToQuickBar) { + await bot.moveSlotItem(slot, 36); + } + + // Select the book slot + bot.setQuickBarSlot(moveToQuickBar ? 0 : slot - 36); + + // Write each page + const hotbarSlot = moveToQuickBar ? 0 : slot - 36; + for (let i = 0; i < pages.length; i++) { + sendBookEdit(hotbarSlot, 'replace_page', { + page: i, + text: pages[i], + }); + } + + // Small delay to let server process + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Restore original quickbar slot + bot.setQuickBarSlot(quickBarSlot); + + // Move book back if we moved it + if (moveToQuickBar) { + await bot.moveSlotItem(36, slot); + } + } + + /** + * Write content to a book and sign it + * + * @param slot - Inventory slot containing the book (0-44) + * @param pages - Array of page contents (strings) + * @param author - Author name for the signed book + * @param title - Title for the signed book + */ + async function signBook(slot: number, pages: string[], author: string, title: string): Promise { + assert.ok(slot >= 0 && slot <= 44, 'slot out of inventory range'); + + const book = bot.inventory.slots[slot]; + const writableBookId = bot.registry.itemsByName.writable_book?.id; + + assert.ok(book && writableBookId && book.type === writableBookId, `no book found in slot ${slot}`); + + const quickBarSlot = bot.quickBarSlot; + const moveToQuickBar = slot < 36; + + // Move book to hotbar if needed + if (moveToQuickBar) { + await bot.moveSlotItem(slot, 36); + } + + // Select the book slot + bot.setQuickBarSlot(moveToQuickBar ? 0 : slot - 36); + + // Write each page first + const hotbarSlot = moveToQuickBar ? 0 : slot - 36; + for (let i = 0; i < pages.length; i++) { + sendBookEdit(hotbarSlot, 'replace_page', { + page: i, + text: pages[i], + }); + } + + // Small delay before signing + await new Promise((resolve) => setTimeout(resolve, 50)); + + // Sign the book + sendBookEdit(hotbarSlot, 'sign', { + title, + author, + }); + + // Wait for inventory update (book becomes written_book) + const inventorySlot = moveToQuickBar ? 36 : slot; + await new Promise((resolve) => { + const handler = (oldItem: unknown, newItem: unknown) => { + // Book was signed when it changes type + const newItemTyped = newItem as { type?: number } | null; + const writtenBookId = bot.registry.itemsByName.written_book?.id; + if (newItemTyped && writtenBookId && newItemTyped.type === writtenBookId) { + bot.inventory.off(`updateSlot:${inventorySlot}`, handler); + resolve(); + } + }; + bot.inventory.on(`updateSlot:${inventorySlot}`, handler); + + // Timeout after 5 seconds + setTimeout(() => { + bot.inventory.off(`updateSlot:${inventorySlot}`, handler); + resolve(); // Resolve anyway, server might not send update + }, 5000); + }); + + // Restore original quickbar slot + bot.setQuickBarSlot(quickBarSlot); + + // Move book back if we moved it + if (moveToQuickBar) { + await bot.moveSlotItem(36, slot); + } + } + + // Expose API on bot object + bot.writeBook = writeBook; + bot.signBook = signBook; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/bossbar.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/bossbar.mts new file mode 100644 index 0000000..85707f1 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/bossbar.mts @@ -0,0 +1,61 @@ +import type { BedrockBot } from '../../index.js'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +export default function inject(bot: BedrockBot) { + const BossBar = require('../bossbar')(bot.registry); + const bars: Record = {}; + + bot.on('entityGone', (entity) => { + if (!entity) return; + if (!(entity.unique_id in bars)) return; + bot.emit('bossBarDeleted', bars[entity.unique_id]); + delete bars[entity.unique_id]; + }); + + bot._client.on('boss_event', (packet) => { + if (packet.type === 'show_bar') { + let flags = 0; + + if (packet.screen_darkening === 1) { + flags |= 0x1; + } + + bars[packet.boss_entity_id] = new BossBar(packet.boss_entity_id, packet.title, getBossHealth(packet.boss_entity_id), 4, packet.color, flags); + + bot._client.write('boss_event', { + boss_entity_id: packet.boss_entity_id, + type: 'register_player', + player_id: bot.entity.unique_id, + }); + + bot.emit('bossBarCreated', bars[packet.boss_entity_id]); + } else if (packet.type === 'set_bar_progress') { + if (!(packet.boss_entity_id in bars)) { + return; + } + + bars[packet.boss_entity_id].health = getBossHealth(packet.boss_entity_id); + + bot.emit('bossBarUpdated', bars[packet.boss_entity_id]); + } + }); + + function getBossHealth(boss_id: string | number) { + let boss_entity = (bot as any).fetchEntity(boss_id); + + let boss_health = 0; + + if ('minecraft:health' in boss_entity.attributes) { + boss_health = boss_entity.attributes['minecraft:health'].value; + } + + return boss_health; + } + + Object.defineProperty(bot, 'bossBars', { + get() { + return Object.values(bars); + }, + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/breath.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/breath.mts new file mode 100644 index 0000000..9f36898 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/breath.mts @@ -0,0 +1,17 @@ +import type { BedrockBot } from '../../index.js'; + +export default function inject(bot: BedrockBot) { + bot._client.on('set_entity_data', (packet) => { + if (!bot?.entity?.id === packet?.runtime_entity_id) return; + if (packet?.metadata[1]?.key === 'air') { + if (!packet?.metadata[1]?.value) return; + bot.oxygenLevel = Math.round(packet.metadata[1].value / 15); + bot.emit('breath'); + } + if (packet?.metadata[0]?.key === 'air') { + if (!packet?.metadata[0]?.value) return; + bot.oxygenLevel = Math.round(packet.metadata[0].value / 15); + bot.emit('breath'); + } + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/chat.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/chat.mts new file mode 100644 index 0000000..1408f71 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/chat.mts @@ -0,0 +1,299 @@ +import type { BedrockBot } from '../../index.js'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +const USERNAME_REGEX = '(?:\\(.{1,15}\\)|\\[.{1,15}\\]|.){0,5}?(\\w+)'; +const LEGACY_VANILLA_CHAT_REGEX = new RegExp(`^${USERNAME_REGEX}\\s?[>:\\-»\\]\\)~]+\\s(.*)$`); + +interface ChatPattern { + name: string; + patterns: RegExp[]; + position: number; + matches: string[]; + messages: any[]; + deprecated?: boolean; + repeat: boolean; + parse: boolean; +} + +interface ChatOptions { + chatLengthLimit?: number; + defaultChatPatterns?: boolean; +} + +export default function inject(bot: BedrockBot, options: ChatOptions = {}) { + const CHAT_LENGTH_LIMIT = options.chatLengthLimit ?? (bot.supportFeature('lessCharsInChat') ? 100 : 256); + const defaultChatPatterns = options.defaultChatPatterns ?? true; + + const ChatMessage = require('prismarine-chat')(bot.registry); + + const _patterns: Record = {}; + let _length = 0; + + // deprecated + bot.chatAddPattern = (patternValue: RegExp, typeValue: string) => { + return bot.addChatPattern(typeValue, patternValue, { deprecated: true }); + }; + + bot.addChatPatternSet = (name: string, patterns: RegExp[], opts: { repeat?: boolean; parse?: boolean } = {}) => { + if (!patterns.every((p) => p instanceof RegExp)) throw new Error('Pattern parameter should be of type RegExp'); + const { repeat = true, parse = false } = opts; + _patterns[_length++] = { + name, + patterns, + position: 0, + matches: [], + messages: [], + repeat, + parse, + }; + return _length; + }; + + bot.addChatPattern = (name: string, pattern: RegExp, opts: { repeat?: boolean; deprecated?: boolean; parse?: boolean } = {}) => { + if (!(pattern instanceof RegExp)) throw new Error('Pattern parameter should be of type RegExp'); + const { repeat = true, deprecated = false, parse = false } = opts; + _patterns[_length] = { + name, + patterns: [pattern], + position: 0, + matches: [], + messages: [], + deprecated, + repeat, + parse, + }; + return _length++; + }; + + bot.removeChatPattern = (name: string | number) => { + if (typeof name === 'number') { + _patterns[name] = undefined; + } else { + const matchingPatterns = Object.entries(_patterns).filter((pattern) => pattern[1]?.name === name); + matchingPatterns.forEach(([indexString]) => { + _patterns[+indexString] = undefined; + }); + } + }; + + function findMatchingPatterns(msg: string): number[] { + const found: number[] = []; + for (const [indexString, pattern] of Object.entries(_patterns)) { + if (!pattern) continue; + const { position, patterns } = pattern; + if (patterns[position].test(msg)) { + found.push(+indexString); + } + } + return found; + } + + bot.on('messagestr', (msg: string, _: any, originalMsg: any) => { + const foundPatterns = findMatchingPatterns(msg); + + for (const ix of foundPatterns) { + const pattern = _patterns[ix]; + if (!pattern) continue; + + pattern.matches.push(msg); + pattern.messages.push(originalMsg); + pattern.position++; + + if (pattern.deprecated) { + const matchResult = pattern.matches[0].match(pattern.patterns[0]); + if (matchResult) { + const [, ...matches] = matchResult; + (bot.emit as any)(pattern.name, ...matches, pattern.messages[0]?.translate, ...pattern.messages); + } + pattern.messages = []; + } else { + if (pattern.patterns.length > pattern.matches.length) continue; + if (pattern.parse) { + const matches = pattern.patterns.map((p, i) => { + const matchResult = pattern.matches[i].match(p); + if (matchResult) { + const [, ...m] = matchResult; + return m; + } + return []; + }); + + bot.emit(`chat:${pattern.name}` as `chat:${string}`, matches); + } else { + (bot.emit as any)(`chat:${pattern.name}`, pattern.matches); + } + } + + if (_patterns[ix]?.repeat) { + _patterns[ix]!.position = 0; + _patterns[ix]!.matches = []; + } else { + _patterns[ix] = undefined; + } + } + }); + + addDefaultPatterns(); + + // Handle incoming text packets + bot._client.on('text', (data) => { + let msg: any; + + if (data.type === 'translation') { + // Handle translation messages with parameters + const params: string[] = []; + if (data.parameters) { + for (const param of data.parameters) { + if (typeof param === 'string' && param.startsWith('%') && bot.registry.language[param.substring(1)] != null) { + params.push(bot.registry.language[param.substring(1)]); + } else { + params.push(param); + } + } + } + msg = new ChatMessage({ translate: data.message, with: params }); + } else if (['json', 'json_whisper', 'json_announcement'].includes(data.type)) { + // Handle JSON/tellraw messages (Bedrock uses rawtext format) + try { + const jsonContent = (data.message || '').trim(); + if (jsonContent) { + const parsed = typeof jsonContent === 'string' ? JSON.parse(jsonContent) : jsonContent; + // Convert Bedrock rawtext format to Java-compatible format + if (parsed.rawtext && Array.isArray(parsed.rawtext)) { + // Convert rawtext array to extra array format that prismarine-chat understands + const converted = { + text: '', + extra: parsed.rawtext.map((item: any) => { + if (typeof item === 'string') return { text: item }; + if (item.text) return { text: item.text }; + if (item.translate) return { translate: item.translate, with: item.with }; + if (item.selector) return { text: item.selector }; // Simplified selector handling + if (item.score) return { text: `${item.score.name}:${item.score.objective}` }; + return item; + }), + }; + msg = new ChatMessage(converted); + } else { + msg = new ChatMessage(parsed); + } + } else { + msg = new ChatMessage({ text: '' }); + } + } catch (e) { + // If JSON parsing fails, treat as plain text + msg = ChatMessage.fromNotch(data.message || ''); + } + } else { + // Handle regular text messages + msg = ChatMessage.fromNotch(data.message || ''); + } + + if (['chat', 'whisper', 'announcement', 'json_whisper', 'json_announcement'].includes(data.type)) { + (bot.emit as any)('message', msg, 'chat', data.source_name, null); + (bot.emit as any)('messagestr', msg.toString(), data.type, msg, data.source_name, null); + } else if (['popup', 'jukebox_popup'].includes(data.type)) { + (bot.emit as any)('actionBar', msg, null); + } else if (data.type === 'json') { + // JSON messages are system/server messages + (bot.emit as any)('message', msg, 'system', null); + (bot.emit as any)('messagestr', msg.toString(), 'system', msg, null); + } else { + (bot.emit as any)('message', msg, data.type, null); + (bot.emit as any)('messagestr', msg.toString(), data.type, msg, null); + } + }); + + function chatWithHeader(message: string | number) { + if (typeof message === 'number') message = message.toString(); + if (typeof message !== 'string') { + throw new Error('Chat message type must be a string or number: ' + typeof message); + } + + if (message.startsWith('/')) { + // Send command via command_request packet (updated for 1.21.130) + // Based on real client packet capture - command includes the leading slash + const client = bot._client as any; + bot._client.write('command_request', { + command: message, // Keep the leading slash - real client sends it + origin: { + type: 'player', + uuid: client.profile?.uuid || bot.player?.uuid || '', + request_id: '', + player_entity_id: 0n, + }, + internal: false, + version: 'latest', + } as any); + return; + } + + const lengthLimit = CHAT_LENGTH_LIMIT; + const client = bot._client as any; + + message.split('\n').forEach((subMessage) => { + if (!subMessage) return; + for (let i = 0; i < subMessage.length; i += lengthLimit) { + const smallMsg = subMessage.substring(i, i + lengthLimit); + + // Construct the text packet with category 'authored' for client-to-server chat + // Updated for 1.21.130 format + bot._client.write('text', { + needs_translation: false, + category: 'authored', + chat: 'chat', + type: 'chat', + whisper: 'whisper', + announcement: 'announcement', + source_name: client.username || '', + message: smallMsg, + xuid: '', + platform_chat_id: '', + has_filtered_message: false, + } as any); + } + }); + } + + async function tabComplete(text: string, assumeCommand = false, sendBlockInSight = true, timeout = 5000): Promise { + // Tab completion is not implemented for Bedrock Edition + // Bedrock uses a different command system that doesn't support client-side tab completion + console.warn('tabComplete is not implemented for Bedrock Edition'); + return []; + } + + bot.whisper = (username: string, message: string) => { + chatWithHeader(`/tell ${username} ${message}`); + }; + + bot.chat = (message: string) => { + chatWithHeader(message); + }; + + bot.tabComplete = tabComplete; + + function addDefaultPatterns() { + if (!defaultChatPatterns) return; + bot.addChatPattern('whisper', new RegExp(`^${USERNAME_REGEX} whispers(?: to you)?:? (.*)$`), { + deprecated: true, + }); + bot.addChatPattern('whisper', new RegExp(`^\\[${USERNAME_REGEX} -> \\w+\\s?\\] (.*)$`), { + deprecated: true, + }); + bot.addChatPattern('chat', LEGACY_VANILLA_CHAT_REGEX, { deprecated: true }); + } + + function awaitMessage(...args: (string | RegExp | (string | RegExp)[])[]): Promise { + return new Promise((resolve) => { + const resolveMessages = args.flatMap((x) => x); + function messageListener(msg: string) { + if (resolveMessages.some((x) => (x instanceof RegExp ? x.test(msg) : msg === x))) { + resolve(msg); + bot.off('messagestr', messageListener); + } + } + bot.on('messagestr', messageListener); + }); + } + bot.awaitMessage = awaitMessage; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/chest.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/chest.mts new file mode 100644 index 0000000..a222f74 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/chest.mts @@ -0,0 +1,113 @@ +/** + * Chest Plugin - Container opening convenience methods for Bedrock + * + * Provides bot.openContainer, bot.openChest, bot.openDispenser aliases + * that wrap bot.openBlock/bot.openEntity with container type validation. + */ + +import type { Block } from 'prismarine-block'; +import type { Entity } from 'prismarine-entity'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot } from '../../index.js'; +import { Vec3 } from 'vec3'; + +// Container block types that can be opened +const CONTAINER_BLOCK_NAMES = [ + 'chest', + 'trapped_chest', + 'ender_chest', + 'barrel', + 'dispenser', + 'dropper', + 'hopper', + // Shulker boxes (all colors) + 'shulker_box', + 'white_shulker_box', + 'orange_shulker_box', + 'magenta_shulker_box', + 'light_blue_shulker_box', + 'yellow_shulker_box', + 'lime_shulker_box', + 'pink_shulker_box', + 'gray_shulker_box', + 'light_gray_shulker_box', + 'cyan_shulker_box', + 'purple_shulker_box', + 'blue_shulker_box', + 'brown_shulker_box', + 'green_shulker_box', + 'red_shulker_box', + 'black_shulker_box', + 'undyed_shulker_box', +]; + +// Window types that are valid containers +const CONTAINER_WINDOW_TYPES = [ + 'minecraft:generic', + 'minecraft:chest', + 'minecraft:dispenser', + 'minecraft:ender_chest', + 'minecraft:shulker_box', + 'minecraft:hopper', + 'minecraft:container', + 'minecraft:dropper', + 'minecraft:trapped_chest', + 'minecraft:barrel', + 'minecraft:generic_9x1', + 'minecraft:generic_9x2', + 'minecraft:generic_9x3', + 'minecraft:generic_9x4', + 'minecraft:generic_9x5', + 'minecraft:generic_9x6', +]; + +function isContainerBlock(block: Block): boolean { + return CONTAINER_BLOCK_NAMES.some((name) => block.name.includes(name)); +} + +function isContainerWindow(window: Window): boolean { + return CONTAINER_WINDOW_TYPES.some((type) => window.type.startsWith(type)); +} + +export default function inject(bot: BedrockBot) { + /** + * Open a container block or entity. + * + * @param containerToOpen - Block or Entity to open + * @param direction - Direction to face when opening (default: up) + * @param cursorPos - Cursor position on block face (default: center) + * @returns Window for the opened container + */ + async function openContainer(containerToOpen: Block | Entity, direction?: Vec3, cursorPos?: Vec3): Promise { + direction = direction ?? new Vec3(0, 1, 0); + cursorPos = cursorPos ?? new Vec3(0.5, 0.5, 0.5); + + let window: Window; + + if (containerToOpen.constructor.name === 'Block') { + const block = containerToOpen as Block; + + if (!isContainerBlock(block)) { + throw new Error(`Block '${block.name}' is not a container`); + } + + window = await bot.openBlock(block, direction, cursorPos); + } else if (containerToOpen.constructor.name === 'Entity') { + const entity = containerToOpen as Entity; + window = await bot.openEntity(entity); + } else { + throw new Error('containerToOpen must be a Block or Entity'); + } + + if (!isContainerWindow(window)) { + throw new Error(`Opened window type '${window.type}' is not a container`); + } + + return window; + } + + // Expose API + bot.openContainer = openContainer; + bot.openChest = openContainer; + bot.openDispenser = openContainer; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/craft.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/craft.mts new file mode 100644 index 0000000..7bfc2a5 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/craft.mts @@ -0,0 +1,1168 @@ +import type { Block } from 'prismarine-block'; +import type { BedrockBot } from '../../index.js'; +import itemLoader, { type Item } from 'prismarine-item'; + +// Import shared utilities from lib/bedrock/ +import { + // Types + type BedrockRecipe, + type Recipe, + + // Recipe utilities + parseRecipe, + fitsIn2x2, + getIngredients, + resolveIngredientId, + itemMatchesIngredient, + countMatchingItems, + findIngredientSlots, + convertToRecipe, + findRecipesByOutput, + hasIngredientsFor, + craftWithAuto, + findItemInAllSlots, + countAllItems, + CraftingSlots, + + // Request utilities + getNextItemStackRequestId, + waitForResponse, + actions, + sendRequest, + slot as makeSlot, + cursor, + getStackId, + getContainerForCursorOp, + + // Workstations + openFurnace as _openFurnace, + openAnvil as _openAnvil, + openEnchantmentTable as _openEnchantmentTable, + openSmithingTable as _openSmithingTable, + openStonecutter as _openStonecutter, + openGrindstone as _openGrindstone, + openLoom as _openLoom, + openBrewingStand as _openBrewingStand, + openCartographyTable as _openCartographyTable, +} from '../bedrock/index.mts'; + +// Re-export types for backwards compatibility +export type { BedrockRecipe, Recipe }; + +/** + * Bedrock Craft Plugin + * + * Implements crafting API compatible with Java mineflayer: + * - bot.craft(recipe, count, craftingTable) + * - bot.recipesFor(itemType, metadata, minResultCount, craftingTable) + * - bot.recipesAll(itemType, metadata, craftingTable) + * + * Additional Bedrock-specific APIs: + * - bot.openStonecutter(block) - Open stonecutter and craft + * - bot.openFurnace(block) - Open furnace for smelting + * - bot.openEnchantmentTable(block) - Open enchanting table + * - bot.openAnvil(block) - Open anvil for repair/rename + * - bot.openSmithingTable(block) - Open smithing table for upgrades + * - bot.openGrindstone(block) - Open grindstone for disenchanting + * - bot.openLoom(block) - Open loom for banner patterns + * - bot.openBrewingStand(block) - Open brewing stand for potions + * - bot.openCartographyTable(block) - Open cartography table for maps + * + * Recipes are received via crafting_data packet at login. + */ + +// Workstation slot constants (kept for local use in 2x2 crafting) +const CRAFTING_2X2_BASE_SLOT = CraftingSlots.CRAFTING_2X2_BASE; +const CRAFTING_3X3_BASE_SLOT = CraftingSlots.CRAFTING_3X3_BASE; + +// Recipe types from Bedrock protocol +type RecipeIngredient = protocolTypes.RecipeIngredient; +type ItemLegacy = protocolTypes.ItemLegacy; + +export default function inject(bot: BedrockBot) { + // Create Item class from registry + const Item = (itemLoader as any)(bot.registry) as typeof import('prismarine-item').Item; + + // Recipe storage indexed by output network_id + const recipesByOutputId = new Map(); + // All recipes indexed by recipe network_id + const recipesByNetworkId = new Map(); + // Unlocked recipes by recipe_id string + const unlockedRecipes = new Set(); + // Flag to track if recipes are loaded + let recipesLoaded = false; + + // Handle unlocked_recipes packet + bot._client.on('unlocked_recipes', (packet: protocolTypes.packet_unlocked_recipes) => { + bot.logger.info(`unlocked_recipes: type=${packet.unlock_type}, count=${packet.recipes?.length ?? 0}, recipes=${packet.recipes?.join(', ')}`); + if (packet.unlock_type === 'remove_all_unlocked') { + unlockedRecipes.clear(); + } else if (packet.unlock_type === 'remove_unlocked') { + for (const recipeId of packet.recipes) { + unlockedRecipes.delete(recipeId); + } + } else { + // initially_unlocked, newly_unlocked, empty + for (const recipeId of packet.recipes) { + unlockedRecipes.add(recipeId); + } + } + bot.logger.debug(`Unlocked recipes total: ${unlockedRecipes.size}`); + }); + + // Handle crafting_data packet - wait for item_registry first (with timeout) + bot._client.on('crafting_data', async (packet: protocolTypes.packet_crafting_data) => { + // Wait for item_registry to be processed first so network_ids are correct + // Use a timeout in case item_registry never arrives (older server versions) + if ((bot as any).item_registry_task) { + await Promise.race([(bot as any).item_registry_task.promise, new Promise((resolve) => setTimeout(resolve, 2000))]); + } + + if (packet.clear_recipes) { + recipesByOutputId.clear(); + recipesByNetworkId.clear(); + } + + for (const entry of packet.recipes) { + const recipe = parseRecipe(entry); + if (!recipe) continue; + + // Index by network_id + recipesByNetworkId.set(recipe.networkId, recipe); + + // Index by output network_id (for recipe lookup) + for (const output of recipe.output) { + const outputId = output.network_id; + if (!recipesByOutputId.has(outputId)) { + recipesByOutputId.set(outputId, []); + } + recipesByOutputId.get(outputId)!.push(recipe); + } + } + + recipesLoaded = true; + bot.logger.debug(`Loaded ${recipesByNetworkId.size} recipes`); + }); + + /** + * Parse a recipe from the crafting_data packet + */ + function parseRecipe(entry: protocolTypes.Recipes[number]): BedrockRecipe | null { + const type = entry.type; + const recipe = entry.recipe; + + if (!recipe) return null; + + switch (type) { + case 'shaped': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + uuid: recipe.uuid, + block: recipe.block, + priority: recipe.priority, + width: recipe.width, + height: recipe.height, + input: recipe.input, + output: recipe.output, + }; + + case 'shapeless': + case 'shulker_box': + case 'shapeless_chemistry': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + uuid: recipe.uuid, + block: recipe.block, + priority: recipe.priority, + input: recipe.input, + output: recipe.output, + }; + + case 'shaped_chemistry': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + uuid: recipe.uuid, + block: recipe.block, + priority: recipe.priority, + width: recipe.width, + height: recipe.height, + input: recipe.input, + output: recipe.output, + }; + + case 'furnace': + case 'furnace_with_metadata': + // Furnace recipes have different structure + return { + type, + recipeId: `furnace_${recipe.input_id}_${recipe.input_meta || 0}`, + networkId: 0, // Furnace recipes don't have network_id + block: recipe.block, + input: [ + { + type: 'int_id_meta', + network_id: recipe.input_id, + metadata: type === 'furnace_with_metadata' ? recipe.input_meta : 32767, + count: 1, + }, + ], + output: [recipe.output], + }; + + case 'smithing_transform': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + block: recipe.tag || 'smithing_table', + template: recipe.template, + base: recipe.base, + addition: recipe.addition, + result: recipe.result, + input: [], + output: recipe.result ? [recipe.result] : [], + }; + + case 'smithing_trim': + return { + type, + recipeId: recipe.recipe_id, + networkId: recipe.network_id, + block: recipe.block, + template: recipe.template, + input: [], + output: [], + }; + + case 'multi': + // Multi recipes are special (e.g., banner patterns, firework stars) + return { + type, + recipeId: `multi_${recipe.uuid}`, + networkId: recipe.network_id, + uuid: recipe.uuid, + block: 'crafting_table', + input: [], + output: [], + }; + + default: + return null; + } + } + + /** + * Check if a recipe can be crafted in 2x2 grid (player inventory) + */ + function fitsIn2x2(recipe: BedrockRecipe): boolean { + if (recipe.type === 'shaped' || recipe.type === 'shaped_chemistry') { + const w = recipe.width || 0; + const h = recipe.height || 0; + return w <= 2 && h <= 2; + } + if (recipe.type === 'shapeless' || recipe.type === 'shulker_box' || recipe.type === 'shapeless_chemistry') { + const ingredientCount = Array.isArray(recipe.input) ? (recipe.input as RecipeIngredient[]).length : 0; + return ingredientCount <= 4; + } + return false; + } + + /** + * Get flattened ingredients from a recipe + */ + function getIngredients(recipe: BedrockRecipe): RecipeIngredient[] { + if (recipe.type === 'shaped' || recipe.type === 'shaped_chemistry') { + // Flatten 2D array + const input = recipe.input as RecipeIngredient[][]; + return input.flat(); + } + return recipe.input as RecipeIngredient[]; + } + + /** + * Resolve an ingredient to a network_id + * Handles different ingredient types: int_id_meta, complex_alias, item_tag + */ + function resolveIngredientId(ing: RecipeIngredient): number { + if (ing.type === 'invalid') return -1; + + // Direct network_id + if (ing.network_id !== undefined && ing.network_id > 0) { + return ing.network_id; + } + + // complex_alias: look up by name + if (ing.type === 'complex_alias' && (ing as any).name) { + const name = (ing as any).name as string; + // Remove "minecraft:" prefix and look up in registry + const shortName = name.replace('minecraft:', ''); + const item = bot.registry.itemsByName[shortName]; + if (item) return item.id; + } + + // item_tag: best-effort resolution based on tag name + // Common tags: minecraft:coals → coal + if (ing.type === 'item_tag' && (ing as any).tag) { + const tag = (ing as any).tag as string; + // Try to extract item name from tag (e.g., "minecraft:coals" → "coal") + const tagName = tag.replace('minecraft:', '').replace(/s$/, ''); // Remove trailing 's' + const item = bot.registry.itemsByName[tagName]; + if (item) return item.id; + } + + return 0; + } + + /** + * Check if an inventory item matches an ingredient + * Handles different ingredient types including item_tag + */ + function itemMatchesIngredient(item: Item, ing: RecipeIngredient): boolean { + if (ing.type === 'invalid') return false; + + // Direct network_id match + if (ing.network_id !== undefined && ing.network_id > 0) { + if (item.type !== ing.network_id) return false; + // Check metadata if specified (32767 = wildcard) + if (ing.metadata !== undefined && ing.metadata !== 32767 && item.metadata !== ing.metadata) { + return false; + } + return true; + } + + // complex_alias: match by name + if (ing.type === 'complex_alias' && (ing as any).name) { + const name = (ing as any).name as string; + const shortName = name.replace('minecraft:', ''); + return item.name === shortName; + } + + // item_tag: check if item has the tag + // For now, use simple name matching (e.g., "minecraft:coals" matches "coal", "charcoal") + if (ing.type === 'item_tag' && (ing as any).tag) { + const tag = (ing as any).tag as string; + const tagName = tag.replace('minecraft:', ''); + // Check common tag patterns + if (tagName === 'coals') { + return item.name === 'coal' || item.name === 'charcoal'; + } + if (tagName === 'logs' || tagName === 'oak_logs') { + return item.name?.endsWith('_log') || item.name?.endsWith('_wood'); + } + if (tagName === 'planks') { + return item.name?.endsWith('_planks'); + } + // Generic: try singular form + const singular = tagName.replace(/s$/, ''); + return item.name === singular || item.name === tagName; + } + + return false; + } + + /** + * Convert Bedrock recipe to prismarine-recipe compatible format + */ + function convertToRecipe(bedrock: BedrockRecipe): Recipe { + const output = bedrock.output[0] || { network_id: 0, count: 1, metadata: 0 }; + + // Determine if recipe requires crafting table + // A recipe requires table if it doesn't fit in 2x2 OR if block is not 'deprecated' + const requiresTable = !fitsIn2x2(bedrock) || (bedrock.block !== 'deprecated' && !fitsIn2x2(bedrock)); + + // Build inShape for shaped recipes + let inShape: { id: number; metadata: number | null }[][] | null = null; + let ingredients: { id: number; metadata: number | null }[] | null = null; + + if (bedrock.type === 'shaped' || bedrock.type === 'shaped_chemistry') { + const input = bedrock.input as RecipeIngredient[][]; + inShape = input.map((row) => + row.map((ing) => ({ + id: ing.type === 'invalid' ? -1 : ing.network_id || 0, + metadata: ing.metadata === 32767 ? null : ing.metadata || null, + })) + ); + } else if (bedrock.type === 'shapeless' || bedrock.type === 'shulker_box' || bedrock.type === 'shapeless_chemistry') { + const input = bedrock.input as RecipeIngredient[]; + ingredients = input.map((ing) => ({ + id: ing.network_id || 0, + metadata: ing.metadata === 32767 ? null : ing.metadata || null, + })); + } + + // Compute delta (inventory change) + const delta: { id: number; metadata: number | null; count: number }[] = []; + + // Add consumed ingredients (negative) + for (const ing of getIngredients(bedrock)) { + if (ing.type === 'invalid') continue; + // Use resolveIngredientId to handle complex_alias and item_tag types + const id = resolveIngredientId(ing); + const metadata = ing.metadata === 32767 ? null : ing.metadata || null; + const existing = delta.find((d) => d.id === id && d.metadata === metadata); + if (existing) { + existing.count -= ing.count; + } else { + delta.push({ id, metadata, count: -ing.count }); + } + } + + // Add produced output (positive) + for (const out of bedrock.output) { + const id = out.network_id; + const metadata = out.metadata || null; + const existing = delta.find((d) => d.id === id && d.metadata === metadata); + if (existing) { + existing.count += out.count; + } else { + delta.push({ id, metadata, count: out.count }); + } + } + + return { + result: { + id: output.network_id, + count: output.count, + metadata: output.metadata || 0, + }, + inShape, + ingredients, + requiresTable, + delta, + networkId: bedrock.networkId, + bedrockRecipe: bedrock, + }; + } + + /** + * Find all recipes that produce a given item + */ + function findRecipesByOutput(itemType: number, metadata: number | null): Recipe[] { + const bedrock = recipesByOutputId.get(itemType) || []; + return bedrock + .filter((r) => { + // Filter by metadata if specified + if (metadata !== null) { + const output = r.output[0]; + if (output && output.metadata !== metadata && output.metadata !== 32767) { + return false; + } + } + return true; + }) + .map(convertToRecipe); + } + + /** + * Count items across ALL inventory slots (hotbar + main inventory) + * bot.inventory.count() only searches inventoryStart-inventoryEnd which may exclude hotbar + */ + function countAllItems(itemType: number, metadata: number | null): number { + let sum = 0; + for (const item of bot.inventory.slots) { + if (item && item.type === itemType && (metadata === null || metadata === 32767 || item.metadata === metadata)) { + sum += item.count; + } + } + return sum; + } + + /** + * Find an item in ALL inventory slots (hotbar + main inventory + all other slots) + * Unlike findInventoryItem which only searches inventoryStart-inventoryEnd, + * this searches all slots and can find by type (number) or name (string) + */ + function findItemInAllSlots(itemTypeOrName: number | string, metadata: number | null): Item | null { + // Search all slots, not just 0-35 + for (let i = 0; i < bot.inventory.slots.length; i++) { + const item = bot.inventory.slots[i]; + if (!item) continue; + + // Match by type (number) or name (string) + const matches = typeof itemTypeOrName === 'number' ? item.type === itemTypeOrName : item.name === itemTypeOrName; + + if (matches && (metadata === null || item.metadata === metadata)) { + return item; + } + } + return null; + } + + /** + * Count items in inventory that match an ingredient + * Handles item_tag and complex_alias types + */ + function countMatchingItems(ing: RecipeIngredient): number { + let count = 0; + for (const item of bot.inventory.slots) { + if (!item) continue; + if (itemMatchesIngredient(item, ing)) { + count += item.count; + } + } + return count; + } + + /** + * Check if player has enough items for a recipe + * Directly checks ingredients to handle item_tag and complex_alias types + */ + function hasIngredientsFor(recipe: Recipe, count: number = 1): boolean { + const bedrock = recipe.bedrockRecipe; + const ingredients = getIngredients(bedrock); + + // Track how many of each ingredient slot we need + const needed = new Map(); + + for (const ing of ingredients) { + if (ing.type === 'invalid') continue; + + // Create a key for this ingredient type + const key = JSON.stringify({ type: ing.type, network_id: ing.network_id, tag: (ing as any).tag, name: (ing as any).name }); + const existing = needed.get(key); + if (existing) { + existing.count += ing.count * count; + } else { + needed.set(key, { ing, count: ing.count * count }); + } + } + + // Check if we have enough of each ingredient + for (const [, { ing, count: neededCount }] of needed) { + const available = countMatchingItems(ing); + if (available < neededCount) { + return false; + } + } + + return true; + } + + /** + * Find recipes for a given item that the player can craft + */ + function recipesFor(itemType: number, metadata: number | null = null, minResultCount: number = 1, craftingTable: Block | boolean | null = null): Recipe[] { + const recipes = findRecipesByOutput(itemType, metadata); + return recipes.filter((recipe) => { + // Check if recipe requires table + if (recipe.requiresTable && !craftingTable) { + return false; + } + + // Check if we have enough ingredients + const craftCount = Math.ceil(minResultCount / recipe.result.count); + return hasIngredientsFor(recipe, craftCount); + }); + } + + /** + * Find all recipes for a given item (regardless of whether player has ingredients) + */ + function recipesAll(itemType: number, metadata: number | null = null, craftingTable: Block | boolean | null = null): Recipe[] { + const recipes = findRecipesByOutput(itemType, metadata); + return recipes.filter((recipe) => { + if (recipe.requiresTable && !craftingTable) { + return false; + } + return true; + }); + } + + /** + * Wait for item_stack_response with given request_id + */ + function waitForStackResponse(requestId: number, timeout: number = 5000): Promise { + return new Promise((resolve) => { + const timer = setTimeout(() => { + bot._client.removeListener('item_stack_response', handler); + resolve(false); + }, timeout); + + const handler = (packet: protocolTypes.packet_item_stack_response) => { + for (const response of packet.responses) { + if (response.request_id === requestId) { + clearTimeout(timer); + bot._client.removeListener('item_stack_response', handler); + bot.logger.debug(`craft response: status=${response.status}`); + resolve(response.status === 'ok'); + return; + } + } + }; + + bot._client.on('item_stack_response', handler); + }); + } + + /** + * Pick up items from inventory slot to cursor + * Uses hotbar/inventory containers matching the existing inventory plugin + */ + async function takeToCursor(sourceSlot: number, count: number): Promise { + const sourceItem = bot.inventory.slots[sourceSlot]; + if (!sourceItem) return false; + + // Map slot to container + if (sourceSlot < 0 || sourceSlot > 35) { + bot.logger.warn(`Unsupported slot index for crafting: ${sourceSlot}`); + return false; + } + + const container = getContainerForCursorOp(sourceSlot); + const stackId = getStackId(sourceItem); + const requestId = getNextItemStackRequestId(); + + bot.logger.info(`takeToCursor: take ${count} from ${container.containerId}:${container.slot} to cursor (item: ${sourceItem.type}, stackId: ${stackId})`); + + sendRequest( + bot, + requestId, + actions() + .takeToCursor(count, makeSlot(container.containerId, container.slot, stackId)) + .build() + ); + + return waitForStackResponse(requestId); + } + + /** + * Place items from cursor to crafting grid slot + * Real client pattern: place from cursor to crafting_input + */ + async function placeFromCursor(craftingSlot: number, count: number): Promise { + const requestId = getNextItemStackRequestId(); + + bot.logger.debug(`placeFromCursor: place ${count} from cursor to crafting_input:${craftingSlot}`); + + sendRequest( + bot, + requestId, + actions() + .placeFromCursor(count, 0, makeSlot('crafting_input', craftingSlot, 0)) + .build() + ); + + return waitForStackResponse(requestId); + } + + /** + * Place an item from inventory to crafting grid using cursor-based approach + * Step 1: Pick up item from inventory to cursor + * Step 2: Place item from cursor to crafting_input + * Returns the stack_id of the placed item + */ + async function placeInCraftingGrid(sourceSlot: number, craftingSlot: number, count: number): Promise<{ success: boolean; stackId: number }> { + const sourceItem = bot.inventory.slots[sourceSlot]; + if (!sourceItem) return { success: false, stackId: 0 }; + + const sourceStackId = getStackId(sourceItem); + + // Step 1: Pick up item to cursor + const takeRequestId = getNextItemStackRequestId(); + + bot.logger.info(`placeInCraftingGrid step1: take ${count} from hotbar_and_inventory:${sourceSlot} to cursor`); + + sendRequest( + bot, + takeRequestId, + actions() + .takeToCursor(count, makeSlot('hotbar_and_inventory', sourceSlot, sourceStackId)) + .build() + ); + + // Wait for take response + const takeSuccess = await waitForStackResponse(takeRequestId); + if (!takeSuccess) { + bot.logger.error('Failed to take item to cursor'); + return { success: false, stackId: 0 }; + } + + // Step 2: Place item from cursor to crafting_input + const placeRequestId = getNextItemStackRequestId(); + + // Get the current crafting window ID + const craftingWindow = bot.currentWindow; + const windowId = craftingWindow?.id ?? 0; + + bot.logger.info(`placeInCraftingGrid step2: place ${count} from cursor to crafting_input:${craftingSlot} (window=${windowId})`); + + sendRequest( + bot, + placeRequestId, + actions() + .placeFromCursor(count, sourceStackId, { + containerId: 'crafting_input', + slot: craftingSlot, + stackId: 0, + dynamicContainerId: windowId, + }) + .build() + ); + + // Wait for place response + const placeSuccess = await waitForStackResponse(placeRequestId); + return { success: placeSuccess, stackId: sourceStackId }; + } + + /** + * Check if a recipe is unlocked for the player + */ + function isRecipeUnlocked(recipeId: string): boolean { + return unlockedRecipes.has(recipeId); + } + + /** + * Find inventory slots containing items that match a recipe ingredient + */ + function findIngredientSlots(ingredient: RecipeIngredient): number[] { + const slots: number[] = []; + + for (let i = 0; i < bot.inventory.slots.length; i++) { + const item = bot.inventory.slots[i]; + if (!item) continue; + + // Use itemMatchesIngredient to handle complex_alias and item_tag types + if (itemMatchesIngredient(item, ingredient)) { + slots.push(i); + } + } + return slots; + } + + /** + * Craft a recipe once using item_stack_request + * Uses craft_recipe_auto for crafting table, craft_recipe with manual placement for 2x2 inventory. + */ + async function craftOnce(recipe: Recipe, craftingTable: Block | null): Promise { + const bedrock = recipe.bedrockRecipe; + + // Check if recipe is unlocked + if (!isRecipeUnlocked(bedrock.recipeId)) { + bot.logger.warn(`Recipe ${bedrock.recipeId} is not unlocked! Attempting anyway...`); + bot.logger.debug(`Unlocked recipes (${unlockedRecipes.size}): ${Array.from(unlockedRecipes).slice(0, 10).join(', ')}...`); + } + + // For shaped/shapeless recipes + if (bedrock.type === 'shaped' || bedrock.type === 'shapeless' || bedrock.type === 'shulker_box' || bedrock.type === 'shapeless_chemistry' || bedrock.type === 'shaped_chemistry') { + // Check if recipe physically requires crafting table (doesn't fit in 2x2) + const needsTable = recipe.requiresTable; + + if (craftingTable) { + // Crafting with table - use craft_recipe_auto (shift-click style) + const craftingWindow = await bot.openBlock(craftingTable); + bot.logger.debug(`Opened crafting table window: ${craftingWindow?.id}`); + await new Promise((resolve) => setTimeout(resolve, 100)); + + try { + await craftWithAuto(bedrock); + } finally { + bot.closeWindow(craftingWindow); + } + } else if (needsTable) { + throw new Error(`Recipe ${bedrock.recipeId} requires crafting table (doesn't fit in 2x2 grid)`); + } else { + // 2x2 crafting WITHOUT table + // NOTE: Bedrock protocol limitation - 2x2 crafting requires the inventory screen to be open, + // which bots cannot do. Users should provide a crafting table for reliable crafting. + // We'll still try craft_recipe_auto but it may be rejected by the server. + bot.logger.debug(`Attempting ${bedrock.recipeId} in 2x2 player inventory - may require crafting table`); + try { + await craftWithAuto(bedrock); + } catch (err) { + throw new Error( + `2x2 crafting without table not supported - Bedrock requires inventory screen to be open. ` + `Please provide a crafting table for recipe ${bedrock.recipeId}. Original error: ${err}` + ); + } + } + } else { + throw new Error(`Recipe type ${bedrock.type} not yet supported for crafting`); + } + } + + /** + * Craft using 2x2 player inventory grid with manual placement + * Based on packet captures: + * 1. Place items from inventory to crafting_input:28-31 + * 2. Send craft_recipe action with consume + take + */ + async function craftWith2x2Manual(bedrock: BedrockRecipe): Promise { + const placedSlots: { craftSlot: number; count: number; stackId: number }[] = []; + + // Place ingredients in 2x2 grid (crafting_input:28-31) + if (bedrock.type === 'shaped' || bedrock.type === 'shaped_chemistry') { + const input = bedrock.input as RecipeIngredient[][]; + const width = bedrock.width || 1; + const height = bedrock.height || 1; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const ing = input[y]?.[x]; + if (!ing || ing.type === 'invalid') continue; + + // Map to 2x2 grid: slot 28 = top-left, 29 = top-right, 30 = bottom-left, 31 = bottom-right + const craftSlot = CRAFTING_2X2_BASE_SLOT + y * 2 + x; + const srcSlots = findIngredientSlots(ing); + if (srcSlots.length === 0) { + throw new Error(`Missing ingredient: type=${ing.type}, id=${ing.network_id}`); + } + + const placeResult = await placeInCraftingGrid2x2(srcSlots[0], craftSlot, ing.count); + if (!placeResult.success) { + throw new Error(`Failed to place ingredient in crafting slot ${craftSlot}`); + } + placedSlots.push({ craftSlot, count: ing.count, stackId: placeResult.stackId }); + } + } + } else { + // Shapeless - place in order + const input = bedrock.input as RecipeIngredient[]; + let slotIndex = 0; + for (const ing of input) { + if (ing.type === 'invalid') continue; + + const craftSlot = CRAFTING_2X2_BASE_SLOT + slotIndex++; + const srcSlots = findIngredientSlots(ing); + if (srcSlots.length === 0) { + throw new Error(`Missing ingredient: type=${ing.type}, id=${ing.network_id}`); + } + + const placeResult = await placeInCraftingGrid2x2(srcSlots[0], craftSlot, ing.count); + if (!placeResult.success) { + throw new Error(`Failed to place ingredient in crafting slot ${craftSlot}`); + } + placedSlots.push({ craftSlot, count: ing.count, stackId: placeResult.stackId }); + } + } + + // Send craft_recipe with consume + take actions + const requestId = getNextItemStackRequestId(); + const outputCount = bedrock.output[0]?.count ?? 1; + + const resultItems = bedrock.output.map((output) => ({ + network_id: output.network_id, + count: output.count, + metadata: output.metadata ?? 0, + block_runtime_id: output.block_runtime_id ?? 0, + extra: output.extra ?? { has_nbt: 0, can_place_on: [], can_destroy: [] }, + })); + + // Build crafting actions using ActionBuilder + const builder = actions().craftRecipe(bedrock.networkId, 1).resultsDeprecated(resultItems, 1); + + // Add consume actions for each placed ingredient + for (const placed of placedSlots) { + builder.consume(placed.count, makeSlot('crafting_input', placed.craftSlot, placed.stackId)); + } + + // Take result to cursor + builder.takeToCursor(outputCount, makeSlot('creative_output', 50, requestId)); + + bot.logger.info(`Sending craft_recipe (2x2): id=${requestId}, recipe=${bedrock.networkId}`); + + sendRequest(bot, requestId, builder.build()); + + const success = await waitForStackResponse(requestId); + if (!success) { + throw new Error('Crafting failed - server rejected craft_recipe request'); + } + + // Move result from cursor to inventory + await bot.putAway(0); + } + + /** + * Place item from inventory to 2x2 crafting grid + * Uses two-step approach: take to cursor, then place in crafting_input + * From packet captures: Real client uses 'inventory' container for slots 9+, 'hotbar' for 0-8 + */ + async function placeInCraftingGrid2x2(sourceSlot: number, craftingSlot: number, count: number): Promise<{ success: boolean; stackId: number }> { + const sourceItem = bot.inventory.slots[sourceSlot]; + if (!sourceItem) return { success: false, stackId: 0 }; + + const sourceStackId = getStackId(sourceItem); + const container = getContainerForCursorOp(sourceSlot); + + // Step 1: Take from inventory/hotbar to cursor + const takeRequestId = getNextItemStackRequestId(); + bot.logger.debug(`2x2 step1: take ${count} from ${container.containerId}:${sourceSlot} to cursor`); + + sendRequest( + bot, + takeRequestId, + actions() + .takeToCursor(count, makeSlot(container.containerId, container.slot, sourceStackId)) + .build() + ); + + const takeSuccess = await waitForStackResponse(takeRequestId); + if (!takeSuccess) { + bot.logger.error('Failed to take item to cursor for 2x2 crafting'); + return { success: false, stackId: 0 }; + } + + // Step 2: Place from cursor to crafting_input + const placeRequestId = getNextItemStackRequestId(); + bot.logger.debug(`2x2 step2: place ${count} from cursor to crafting_input:${craftingSlot}`); + + sendRequest( + bot, + placeRequestId, + actions() + .placeFromCursor(count, sourceStackId, makeSlot('crafting_input', craftingSlot, 0)) + .build() + ); + + const placeSuccess = await waitForStackResponse(placeRequestId); + return { success: placeSuccess, stackId: sourceStackId }; + } + + /** + * Craft using craft_recipe_auto action + * This tells the server to automatically source ingredients. + * Based on real client packet captures, the format is: + * 1. craft_recipe_auto with recipe_network_id, times_crafted, times_crafted_2, ingredients + * 2. results_deprecated with result_items and times_crafted + * 3. consume actions from hotbar_and_inventory for each ingredient slot + * 4. place action from creative_output:50 to hotbar_and_inventory + */ + async function craftWithAuto(bedrock: BedrockRecipe): Promise { + const requestId = getNextItemStackRequestId(); + const outputCount = bedrock.output[0]?.count ?? 1; + const ingredients = getIngredients(bedrock).filter((ing) => ing.type !== 'invalid'); + + // Build result_items for results_deprecated + const resultItems = bedrock.output.map((output) => ({ + network_id: output.network_id, + count: output.count, + metadata: output.metadata ?? 0, + block_runtime_id: output.block_runtime_id ?? 0, + extra: output.extra ?? { has_nbt: 0, can_place_on: [], can_destroy: [] }, + })); + + // Find inventory slots with ingredients + // Also resolve item_tag/complex_alias to actual int_id_meta for the ingredients array + const ingredientCounts = new Map(); + const resolvedIngredients: RecipeIngredient[] = []; + + for (const ing of ingredients) { + if (ing.type === 'invalid') continue; + + // Find a slot with this ingredient using itemMatchesIngredient + for (let slot = 0; slot < bot.inventory.slots.length; slot++) { + const item = bot.inventory.slots[slot]; + if (!item) continue; + if (!itemMatchesIngredient(item, ing)) continue; + + const key = slot; + const existing = ingredientCounts.get(key); + if (existing) { + existing.count += ing.count; + } else { + ingredientCounts.set(key, { + slot, + stackId: (item as any).stackId ?? 0, + count: ing.count, + }); + } + + // Resolve item_tag/complex_alias to int_id_meta using the actual item + if (ing.type === 'item_tag' || ing.type === 'complex_alias') { + resolvedIngredients.push({ + type: 'int_id_meta', + network_id: item.type, + metadata: item.metadata ?? 32767, + count: ing.count, + } as RecipeIngredient); + } else { + resolvedIngredients.push(ing); + } + + break; + } + } + + // Find an empty slot for the output + let outputSlot = -1; + for (let slot = 0; slot < bot.inventory.slots.length; slot++) { + if (!bot.inventory.slots[slot]) { + outputSlot = slot; + break; + } + } + if (outputSlot === -1) { + throw new Error('No empty inventory slot for crafting output'); + } + + // Build crafting actions using ActionBuilder + const builder = actions().craftRecipeAuto(bedrock.networkId, 1, resolvedIngredients).resultsDeprecated(resultItems, 1); + + // Add consume actions for each ingredient + let firstConsume = true; + for (const [, info] of ingredientCounts) { + const stackId = firstConsume ? info.stackId : requestId; + builder.consume(info.count, makeSlot('hotbar_and_inventory', info.slot, stackId)); + firstConsume = false; + } + + // Place result directly to inventory + builder.place(outputCount, makeSlot('creative_output', 50, requestId), makeSlot('hotbar_and_inventory', outputSlot, 0)); + + bot.logger.info(`Sending craft_recipe_auto: id=${requestId}, recipe=${bedrock.networkId}`); + bot.logger.debug(` original ingredients: ${JSON.stringify(ingredients)}`); + bot.logger.debug(` resolved ingredients: ${JSON.stringify(resolvedIngredients)}`); + + sendRequest(bot, requestId, builder.build()); + + const success = await waitForStackResponse(requestId); + if (!success) { + throw new Error('Crafting failed - server rejected craft_recipe_auto request'); + } + + // Create the output item in the destination slot + // item_stack_response only updates counts, not create new items + const output = bedrock.output[0]; + if (output) { + const newItem = new Item(output.network_id, outputCount, output.metadata ?? 0); + (newItem as any).stackId = requestId; // Use request_id as stack_id + bot.inventory.updateSlot(outputSlot, newItem); + bot.logger.debug(`Created crafted item in slot ${outputSlot}: ${output.network_id} x${outputCount}`); + } + } + + /** + * Craft with manual item placement (for when container window is open) + */ + async function craftWithPlacement(bedrock: BedrockRecipe, baseSlot: number, gridSize: number): Promise { + const placedSlots: { craftSlot: number; count: number; stackId: number }[] = []; + let slotIndex = 0; + + if (bedrock.type === 'shaped' || bedrock.type === 'shaped_chemistry') { + const input = bedrock.input as RecipeIngredient[][]; + const width = bedrock.width || 1; + const height = bedrock.height || 1; + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const ing = input[y]?.[x]; + if (!ing || ing.type === 'invalid') continue; + + const craftSlot = baseSlot + y * gridSize + x; + const srcSlots = findIngredientSlots(ing); + if (srcSlots.length === 0) { + throw new Error(`Missing ingredient: type=${ing.type}, id=${ing.network_id}`); + } + + const placeResult = await placeInCraftingGrid(srcSlots[0], craftSlot, ing.count); + if (!placeResult.success) { + throw new Error(`Failed to place ingredient in crafting slot ${craftSlot}`); + } + placedSlots.push({ craftSlot, count: ing.count, stackId: placeResult.stackId }); + } + } + } else { + const input = bedrock.input as RecipeIngredient[]; + for (const ing of input) { + if (ing.type === 'invalid') continue; + + const craftSlot = baseSlot + slotIndex++; + const srcSlots = findIngredientSlots(ing); + if (srcSlots.length === 0) { + throw new Error(`Missing ingredient: type=${ing.type}, id=${ing.network_id}`); + } + + const placeResult = await placeInCraftingGrid(srcSlots[0], craftSlot, ing.count); + if (!placeResult.success) { + throw new Error(`Failed to place ingredient in crafting slot ${craftSlot}`); + } + placedSlots.push({ craftSlot, count: ing.count, stackId: placeResult.stackId }); + } + } + + // Now send craft_recipe + consume + take + const requestId = getNextItemStackRequestId(); + const outputCount = bedrock.output[0]?.count ?? 1; + + // Build result_items for results_deprecated based on recipe output + const resultItems = bedrock.output.map((output) => ({ + network_id: output.network_id, + count: output.count, + metadata: output.metadata ?? 0, + block_runtime_id: output.block_runtime_id ?? 0, + extra: output.extra ?? { has_nbt: 0, can_place_on: [], can_destroy: [] }, + })); + + // Build crafting actions using ActionBuilder + const builder = actions().craftRecipe(bedrock.networkId, 1).resultsDeprecated(resultItems, 1); + + // Add consume actions for each placed ingredient + for (const placed of placedSlots) { + builder.consume(placed.count, makeSlot('crafting_input', placed.craftSlot, 0)); + } + + // Take result to cursor + builder.takeToCursor(outputCount, makeSlot('creative_output', 50, 0)); + + bot.logger.info(`Sending craft_recipe: id=${requestId}, recipe=${bedrock.networkId}`); + + sendRequest(bot, requestId, builder.build()); + + const success = await waitForStackResponse(requestId); + if (!success) { + throw new Error('Crafting failed - server rejected craft_recipe request'); + } + + await bot.putAway(0); + } + + /** + * Craft a recipe multiple times + */ + async function craft(recipe: Recipe, count: number = 1, craftingTable: Block | null = null): Promise { + if (!recipe) { + throw new Error('Recipe is required'); + } + + count = parseInt(String(count ?? 1), 10); + + if (recipe.requiresTable && !craftingTable) { + throw new Error('Recipe requires craftingTable, but one was not supplied'); + } + + for (let i = 0; i < count; i++) { + await craftOnce(recipe, craftingTable); + } + } + + // Expose API + bot.craft = craft; + bot.recipesFor = recipesFor; + bot.recipesAll = recipesAll; + + // Bedrock-specific workstation APIs - use shared implementations from lib/bedrock/ + (bot as any).openStonecutter = (block: Block) => _openStonecutter(bot, block); + (bot as any).openFurnace = (block: Block) => _openFurnace(bot, block); + (bot as any).openEnchantmentTable = (block: Block) => _openEnchantmentTable(bot, block); + (bot as any).openAnvil = (block: Block) => _openAnvil(bot, block); + (bot as any).openSmithingTable = (block: Block) => _openSmithingTable(bot, block); + (bot as any).openGrindstone = (block: Block) => _openGrindstone(bot, block); + (bot as any).openLoom = (block: Block) => _openLoom(bot, block); + (bot as any).openBrewingStand = (block: Block) => _openBrewingStand(bot, block); + (bot as any).openCartographyTable = (block: Block) => _openCartographyTable(bot, block); + + // Expose recipe data for debugging + (bot as any)._recipes = { + byOutputId: recipesByOutputId, + byNetworkId: recipesByNetworkId, + unlockedRecipes, + isRecipeUnlocked, + get loaded() { + return recipesLoaded; + }, + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/creative.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/creative.mts new file mode 100644 index 0000000..9ab89ff --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/creative.mts @@ -0,0 +1,304 @@ +/** + * Creative Mode Plugin for Bedrock Edition + * + * Provides creative mode functionality: + * - setInventorySlot: Pick items from creative inventory into player slots + * - clearSlot: Remove item from a slot + * - clearInventory: Clear all inventory slots + * - flyTo: Fly to a destination in creative mode + * - startFlying / stopFlying: Toggle creative flight + * + * Protocol: + * - Items are picked using item_stack_request with craft_creative action + * - Flow: craft_creative -> results_deprecated -> take to cursor -> place to slot + * - creative_content packet provides available creative items + */ + +import type { BedrockBot } from '../../index.js'; +import type { Vec3 } from 'vec3'; +import itemLoader, { type Item } from 'prismarine-item'; +import { Vec3 as Vec3Constructor } from 'vec3'; + +import { + getNextItemStackRequestId, + getStackId, + actions, + sendRequest, + waitForResponse, + slot, + cursor, + fromPlayerSlot, + ContainerIds, +} from '../bedrock/index.mts'; + +const CREATIVE_OUTPUT_SLOT = 50; +const FLYING_SPEED_PER_UPDATE = 0.5; + +export default function inject(bot: BedrockBot) { + const Item = (itemLoader as any)(bot.registry) as typeof Item; + + // Track creative items from creative_content packet + // Maps network_id -> entry_id (for use in craft_creative action) + let networkIdToEntryId: Map = new Map(); + + // Parse creative_content packet + bot._client.on('creative_content', (packet: any) => { + networkIdToEntryId.clear(); + + if (packet.items) { + for (const entry of packet.items) { + const item = entry.item; + if (item && item.network_id) { + // Map network_id to entry_id for craft_creative lookup + networkIdToEntryId.set(item.network_id, entry.entry_id); + } + } + bot.logger.debug(`Creative content: ${packet.items.length} items indexed`); + } + }); + + // Track pending slot updates to prevent duplicate requests + const pendingSlotUpdates: Set = new Set(); + + /** + * Set an inventory slot to a specific item (creative mode only) + * + * @param slotIndex - The slot index (0-44) + * @param item - The item to set, or null to clear + * @param waitTimeout - Timeout to wait for rejection (default 400ms) + */ + async function setInventorySlot(slotIndex: number, item: Item | null, waitTimeout: number = 400): Promise { + if (slotIndex < 0 || slotIndex > 44) { + throw new Error(`Invalid slot index: ${slotIndex}. Must be 0-44.`); + } + + const currentItem = bot.inventory.slots[slotIndex]; + + // If already same item, skip + if (Item.equal(currentItem, item, true)) return; + + // Prevent concurrent updates to same slot + if (pendingSlotUpdates.has(slotIndex)) { + throw new Error(`Setting slot ${slotIndex} cancelled due to calling bot.creative.setInventorySlot(${slotIndex}, ...) again`); + } + + pendingSlotUpdates.add(slotIndex); + + try { + if (item === null) { + // Clear the slot - destroy the item + await clearSlotInternal(slotIndex); + } else { + // Set item in slot using creative pick + await setSlotItem(slotIndex, item); + } + + // Wait a bit to allow server to potentially reject + if (waitTimeout > 0) { + await new Promise((resolve) => setTimeout(resolve, waitTimeout)); + } + } finally { + pendingSlotUpdates.delete(slotIndex); + } + } + + /** + * Internal: Clear a slot by destroying its contents + */ + async function clearSlotInternal(slotIndex: number): Promise { + const item = bot.inventory.slots[slotIndex]; + if (!item) return; + + const loc = fromPlayerSlot(slotIndex, item); + const requestId = getNextItemStackRequestId(); + + sendRequest(bot, requestId, actions().destroy(item.count, loc).build()); + + const success = await waitForResponse(bot, requestId); + if (success) { + bot.inventory.updateSlot(slotIndex, null); + } + } + + /** + * Internal: Set item in slot using creative pick flow + * + * Protocol flow from packet capture: + * 1. item_stack_request with: + * - craft_creative action + * - results_deprecated with the item + * - take from creative_output (slot 50) to cursor + * 2. item_stack_request to place from cursor to destination + */ + async function setSlotItem(slotIndex: number, item: Item): Promise { + // First clear the slot if it has an item + const currentItem = bot.inventory.slots[slotIndex]; + if (currentItem) { + await clearSlotInternal(slotIndex); + } + + // Step 1: Pick item from creative inventory to cursor + const pickRequestId = getNextItemStackRequestId(); + + // Build the item in ItemLegacy format for results_deprecated + const itemNotch = Item.toNotch(item, 0); + const networkId = itemNotch.network_id; + + // Look up the creative entry_id for this item's network_id + const entryId = networkIdToEntryId.get(networkId); + if (entryId === undefined) { + throw new Error(`Item ${item.name} (network_id=${networkId}) not found in creative inventory`); + } + + const itemLegacy = { + network_id: networkId, + count: item.count, + metadata: item.metadata || 0, + stack_size: 64, + block_runtime_id: itemNotch.block_runtime_id || 0, + extra: { has_nbt: 0, can_place_on: [], can_destroy: [] }, + }; + + // Send pick request: craft_creative + results_deprecated + // Note: Protocol investigation needed - currently returns status 7 error + const actionList = actions() + .craftCreative(entryId) + .resultsDeprecated([itemLegacy]) + .build(); + + bot.logger.debug(`Creative pick: entryId=${entryId}, network_id=${networkId}, count=${item.count}`); + + sendRequest(bot, pickRequestId, actionList); + + const pickSuccess = await waitForResponse(bot, pickRequestId); + if (!pickSuccess) { + throw new Error('Failed to pick item from creative inventory'); + } + + // Small delay between requests (like real client) + await new Promise((r) => setTimeout(r, 50)); + + // Step 2: Place from cursor to destination slot + const placeRequestId = getNextItemStackRequestId(); + const destLoc = fromPlayerSlot(slotIndex, null); + + // Use the negative request ID as stack ID (like real client does) + const cursorStackId = pickRequestId; + + sendRequest( + bot, + placeRequestId, + actions() + .place(item.count, cursor(cursorStackId), slot(destLoc.containerId, destLoc.slot, 0)) + .build() + ); + + const placeSuccess = await waitForResponse(bot, placeRequestId); + if (!placeSuccess) { + throw new Error('Failed to place item from cursor to slot'); + } + + // Update local inventory state + const newItem = new Item(item.type, item.count, item.metadata, item.nbt); + newItem.slot = slotIndex; + bot.inventory.updateSlot(slotIndex, newItem); + } + + /** + * Clear a specific slot + */ + function clearSlot(slotIndex: number): Promise { + return setInventorySlot(slotIndex, null); + } + + /** + * Clear all inventory slots + */ + async function clearInventory(): Promise { + const promises: Promise[] = []; + + for (let i = 0; i < bot.inventory.slots.length; i++) { + const item = bot.inventory.slots[i]; + if (item) { + promises.push(setInventorySlot(i, null)); + } + } + + await Promise.all(promises); + } + + // Flight state + let normalGravity: number | null = null; + + /** + * Fly to a destination (straight line, ensure clear path) + */ + async function flyTo(destination: Vec3): Promise { + startFlying(); + + let vector = destination.minus(bot.entity.position); + let magnitude = vecMagnitude(vector); + + while (magnitude > FLYING_SPEED_PER_UPDATE) { + bot.physics.gravity = 0; + bot.entity.velocity = new Vec3Constructor(0, 0, 0); + + // Move in small steps + const normalizedVector = vector.scaled(1 / magnitude); + bot.entity.position.add(normalizedVector.scaled(FLYING_SPEED_PER_UPDATE)); + + await sleep(50); + + vector = destination.minus(bot.entity.position); + magnitude = vecMagnitude(vector); + } + + // Final step + bot.entity.position = destination; + + // Wait for move event + await new Promise((resolve) => { + bot.once('move', resolve); + // Don't wait forever if move doesn't fire + setTimeout(resolve, 1000); + }); + } + + /** + * Start flying (disable gravity) + */ + function startFlying(): void { + if (normalGravity === null) { + normalGravity = bot.physics.gravity; + } + bot.physics.gravity = 0; + } + + /** + * Stop flying (restore gravity) + */ + function stopFlying(): void { + if (normalGravity !== null) { + bot.physics.gravity = normalGravity; + } + } + + // Expose creative API + bot.creative = { + setInventorySlot, + clearSlot, + clearInventory, + flyTo, + startFlying, + stopFlying, + }; +} + +// Utility functions +function vecMagnitude(vec: Vec3): number { + return Math.sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/digging.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/digging.mts new file mode 100644 index 0000000..da85c52 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/digging.mts @@ -0,0 +1,366 @@ +import type { BedrockBot } from '../../index.js'; +import type { Block } from 'prismarine-block'; +import type { Vec3 } from 'vec3'; +import { performance } from 'perf_hooks'; +// @ts-ignore +import { createDoneTask, createTask } from '../promise_utils.js'; + +const BlockFaces = { + BOTTOM: 0, + TOP: 1, + NORTH: 2, + SOUTH: 3, + WEST: 4, + EAST: 5, +}; + +export default function inject(bot: BedrockBot) { + let swingInterval: ReturnType | null = null; + let continueBreakInterval: ReturnType | null = null; + let waitTimeout: ReturnType | null = null; + let diggingTask = createDoneTask(); + + bot.targetDigBlock = null; + bot.targetDigFace = null; + bot.lastDigTime = null; + + async function dig(block: Block, forceLook?: boolean | 'ignore', digFace?: Vec3 | 'auto' | 'raycast'): Promise { + if (block === null || block === undefined) { + throw new Error('dig was called with an undefined or null block'); + } + + if (!digFace || typeof digFace === 'function') { + digFace = 'auto'; + } + + const waitTime = bot.digTime(block); + if (waitTime === Infinity) { + throw new Error(`dig time for ${block?.name ?? block} is Infinity`); + } + + bot.targetDigFace = BlockFaces.TOP; // Default + + if (forceLook !== 'ignore') { + // Calculate which face to mine based on position + if (digFace && typeof digFace === 'object' && (digFace.x || digFace.y || digFace.z)) { + if (digFace.x) { + bot.targetDigFace = digFace.x > 0 ? BlockFaces.EAST : BlockFaces.WEST; + } else if (digFace.y) { + bot.targetDigFace = digFace.y > 0 ? BlockFaces.TOP : BlockFaces.BOTTOM; + } else if (digFace.z) { + bot.targetDigFace = digFace.z > 0 ? BlockFaces.SOUTH : BlockFaces.NORTH; + } + await bot.lookAt(block.position.offset(0.5, 0.5, 0.5).offset(digFace.x * 0.5, digFace.y * 0.5, digFace.z * 0.5), forceLook); + } else if (digFace === 'raycast') { + // Use raycast to find visible face + const delta = block.position.offset(0.5, 0.5, 0.5).minus(bot.entity.position.offset(0, bot.entity.height, 0)); + if (Math.abs(delta.y) > Math.abs(delta.x) && Math.abs(delta.y) > Math.abs(delta.z)) { + bot.targetDigFace = delta.y > 0 ? BlockFaces.BOTTOM : BlockFaces.TOP; + } else if (Math.abs(delta.x) > Math.abs(delta.z)) { + bot.targetDigFace = delta.x > 0 ? BlockFaces.WEST : BlockFaces.EAST; + } else { + bot.targetDigFace = delta.z > 0 ? BlockFaces.NORTH : BlockFaces.SOUTH; + } + await bot.lookAt(block.position.offset(0.5, 0.5, 0.5), forceLook); + } else { + // auto - look at center + await bot.lookAt(block.position.offset(0.5, 0.5, 0.5), forceLook); + } + } + + // Cancel any existing dig + if (bot.targetDigBlock) bot.stopDigging(); + + diggingTask = createTask(); + const pos = block.position; + const face = bot.targetDigFace; + + bot.targetDigBlock = block; + + // Check if this is an instant break (crops, etc.) + const isInstantBreak = waitTime <= 50; + + if (isInstantBreak) { + // For instant breaks, combine start_break + predict_break in same packet + bot.swingArm('right', true, 'mine'); + bot.swingArm('right', true, 'mine'); + bot.swingArm('right', true, 'mine'); + bot.swingArm('right', true, 'mine'); + bot.swingArm('right', true, 'mine'); + + const heldItem = bot.heldItem; + const itemStackRequest = heldItem + ? { + requests: [ + { + request_id: bot.getNextItemStackRequestId(), + actions: [ + { + type_id: 'mine_block', + hotbar_slot: bot.quickBarSlot ?? 0, + predicted_durability: (heldItem.durabilityUsed ?? 0) + 1, + network_id: heldItem.stackId ?? 0, + }, + ], + custom_names: [], + cause: -1, + }, + ], + } + : undefined; + + await bot.sendPlayerAuthInputTransaction( + { + block_action: [ + { action: 'start_break', position: { x: pos.x, y: pos.y, z: pos.z }, face: face }, + { action: 'predict_break', position: { x: pos.x, y: pos.y, z: pos.z }, face: face }, + ], + item_stack_request: itemStackRequest, + }, + false + ); + + // Update local block state + const airStateId = bot.registry.blocksByName.air?.defaultState ?? 0; + bot._updateBlockState(bot.targetDigBlock.position, airStateId); + + // Schedule abort_break after a tick + waitTimeout = setTimeout(() => finishInstantDigging(pos, face), 50); + } else { + // Send start_break for non-instant breaks + await bot.sendPlayerAuthInputTransaction( + { + block_action: [ + { + action: 'start_break', + position: { x: pos.x, y: pos.y, z: pos.z }, + face: face, + }, + ], + }, + false + ); + + bot.swingArm('right', true, 'mine'); + + // Swing arm every 350ms + swingInterval = setInterval(() => { + bot.swingArm('right', true, 'mine'); + }, 350); + + // Send continue_break every 50ms (every tick) + continueBreakInterval = setInterval(async () => { + if (bot.targetDigBlock) { + await bot.sendPlayerAuthInputTransaction( + { + block_action: [ + { + action: 'continue_break', + position: { x: pos.x, y: pos.y, z: pos.z }, + face: face, + }, + ], + }, + false + ); + } + }, 50); + + // Schedule finish digging + waitTimeout = setTimeout(() => finishDigging(pos, face), waitTime); + } + + async function finishInstantDigging(pos: { x: number; y: number; z: number }, face: number) { + clearTimeout(waitTimeout!); + waitTimeout = null; + + // Send abort_break after instant break completes + await bot.sendPlayerAuthInputTransaction( + { + block_action: [ + { + action: 'abort_break', + position: { x: pos.x, y: pos.y, z: pos.z }, + face: 0, + }, + ], + }, + false + ); + + bot.targetDigBlock = null; + bot.targetDigFace = null; + bot.lastDigTime = performance.now(); + bot.emit('diggingCompleted', block); + diggingTask.finish(); + } + + async function finishDigging(pos: { x: number; y: number; z: number }, face: number) { + clearInterval(swingInterval!); + clearInterval(continueBreakInterval!); + clearTimeout(waitTimeout!); + swingInterval = null; + continueBreakInterval = null; + waitTimeout = null; + + if (bot.targetDigBlock) { + // Send continue_break + predict_break together with item_stack_request for tool durability + const heldItem = bot.heldItem; + const itemStackRequest = heldItem + ? { + requests: [ + { + request_id: bot.getNextItemStackRequestId(), + actions: [ + { + type_id: 'mine_block', + hotbar_slot: bot.quickBarSlot ?? 0, + predicted_durability: (heldItem.durabilityUsed ?? 0) + 1, + network_id: heldItem.stackId ?? 0, + }, + ], + custom_names: [], + cause: -1, + }, + ], + } + : undefined; + + await bot.sendPlayerAuthInputTransaction( + { + block_action: [ + { action: 'continue_break', position: pos, face: face }, + { action: 'predict_break', position: pos, face: face }, + ], + item_stack_request: itemStackRequest, + }, + false + ); + + // Update local block state - use air block's stateId (not 0!) + const airStateId = bot.registry.blocksByName.air?.defaultState ?? 0; + bot._updateBlockState(bot.targetDigBlock.position, airStateId); + + await bot.waitForTicks(3); + await bot.sendPlayerAuthInputTransaction( + { + block_action: [ + { + action: 'abort_break', + position: { x: pos.x, y: pos.y, z: pos.z }, + face: 0, + }, + ], + }, + false + ); + } + + bot.targetDigBlock = null; + bot.targetDigFace = null; + bot.lastDigTime = performance.now(); + } + + // Listen for block update confirmation + const eventName = `blockUpdate:${block.position}`; + bot.on(eventName, onBlockUpdate); + + const currentBlock = block; + bot.stopDigging = () => { + if (!bot.targetDigBlock) return; + + bot.removeListener(eventName, onBlockUpdate); + clearInterval(swingInterval!); + clearInterval(continueBreakInterval!); + clearTimeout(waitTimeout!); + swingInterval = null; + continueBreakInterval = null; + waitTimeout = null; + + // Send abort_break + const abortPos = bot.targetDigBlock.position; + bot.sendPlayerAuthInputTransaction( + { + block_action: [ + { + action: 'abort_break', + position: { x: abortPos.x, y: abortPos.y, z: abortPos.z }, + face: bot.targetDigFace ?? 0, + }, + ], + }, + false + ); + + const abortedBlock = bot.targetDigBlock; + bot.targetDigBlock = null; + bot.targetDigFace = null; + bot.lastDigTime = performance.now(); + bot.emit('diggingAborted', abortedBlock); + bot.stopDigging = noop; + diggingTask.cancel(new Error('Digging aborted')); + }; + + function onBlockUpdate(oldBlock: Block | null, newBlock: Block | null) { + // Block update received - check if block is now air + if (newBlock?.type !== 0) return; + + bot.removeListener(eventName, onBlockUpdate); + clearInterval(swingInterval!); + clearInterval(continueBreakInterval!); + clearTimeout(waitTimeout!); + swingInterval = null; + continueBreakInterval = null; + waitTimeout = null; + bot.targetDigBlock = null; + bot.targetDigFace = null; + bot.lastDigTime = performance.now(); + bot.emit('diggingCompleted', newBlock); + diggingTask.finish(); + } + + await diggingTask.promise; + } + + bot.on('death', () => { + bot.removeAllListeners('diggingAborted'); + bot.removeAllListeners('diggingCompleted'); + bot.stopDigging(); + }); + + function canDigBlock(block: Block): boolean { + return block && block.diggable && block.position.offset(0.5, 0.5, 0.5).distanceTo(bot.entity.position.offset(0, 1.65, 0)) <= 5.1; + } + + function digTime(block: Block): number { + let type = null; + let enchantments: any[] = []; + + const currentlyHeldItem = bot.heldItem; + if (currentlyHeldItem) { + type = currentlyHeldItem.type; + enchantments = currentlyHeldItem.enchants || []; + } + + // Append helmet enchantments (Aqua Affinity affects dig speed) + const headEquipmentSlot = bot.getEquipmentDestSlot?.('head'); + if (headEquipmentSlot !== undefined) { + const headEquippedItem = bot.inventory?.slots?.[headEquipmentSlot]; + if (headEquippedItem?.enchants) { + enchantments = enchantments.concat(headEquippedItem.enchants); + } + } + + const creative = bot.game?.gameMode === 'creative'; + return block.digTime(type, creative, bot.entity?.isInWater ?? false, !(bot.entity?.onGround ?? true), enchantments, bot.entity?.effects ?? {}); + } + + bot.dig = dig; + bot.stopDigging = noop; + bot.canDigBlock = canDigBlock; + bot.digTime = digTime; +} + +function noop(err?: Error) { + if (err) throw err; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/entities.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/entities.mts new file mode 100644 index 0000000..0b3933a --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/entities.mts @@ -0,0 +1,601 @@ +import type { BedrockBot } from '../../index.js'; +import { Vec3 } from 'vec3'; +import conv from '../conversions.js'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +const NAMED_ENTITY_HEIGHT = 1.62; +const NAMED_ENTITY_WIDTH = 0.6; +const CROUCH_HEIGHT = NAMED_ENTITY_HEIGHT - 0.08; + +export default function inject(bot: BedrockBot) { + const { mobs, entitiesArray } = bot.registry; + const Entity = require('prismarine-entity')(bot.registry); + const Item = require('prismarine-item')(bot.registry); + const ChatMessage = require('prismarine-chat')(bot.registry); + + bot.findPlayer = bot.findPlayers = (filter: any) => { + const filterFn = (entity: any) => { + if (entity.type !== 'player') return false; + if (filter === null) return true; + if (typeof filter === 'object' && filter instanceof RegExp) { + return entity.username.search(filter) !== -1; + } else if (typeof filter === 'function') { + return filter(entity); + } else if (typeof filter === 'string') { + return entity.username.toLowerCase() === filter.toLowerCase(); + } + return false; + }; + const resultSet = Object.values(bot.entities).filter(filterFn); + + if (typeof filter === 'string') { + switch (resultSet.length) { + case 0: + return null; + case 1: + return resultSet[0]; + default: + return resultSet; + } + } + return resultSet; + }; + + bot.players = {}; + bot.uuidToUsername = {}; + bot.entities = {}; + + bot._playerFromUUID = (uuid: string) => Object.values(bot.players).find((player: any) => player.uuid === uuid); + + bot.nearestEntity = ( + match = (entity: any) => { + return true; + } + ) => { + let best: any = null; + let bestDistance = Number.MAX_VALUE; + + for (const entity of Object.values(bot.entities) as any[]) { + if (entity === bot.entity || !match(entity)) { + continue; + } + + const dist = bot.entity.position.distanceSquared(entity.position); + if (dist < bestDistance && entity.id) { + best = entity; + bestDistance = dist; + } + } + + return best; + }; + + // Reset list of players and entities on login + bot._client.on('start_game', (packet) => { + bot.players = {}; + bot.uuidToUsername = {}; + bot.entities = {}; + // login + bot.entity = fetchEntity(packet.runtime_entity_id); + bot.username = bot._client.username; + bot.entity.username = bot._client.username; + bot.entity.type = 'player'; + bot.entity.name = 'player'; + }); + + // bot._client.on('entity_equipment', (packet) => { + // // entity equipment + // const entity = fetchEntity(packet.entityId) + // if (packet.equipments !== undefined) { + // packet.equipments.forEach(equipment => entity.setEquipment(equipment.slot, equipment.item ? Item.fromNotch(equipment.item) : null)) + // } else { + // entity.setEquipment(packet.slot, packet.item ? Item.fromNotch(packet.item) : null) + // } + // bot.emit('entityEquip', entity) + // }) + + bot._client.on('add_player', (packet) => { + // CHANGE + // in case player_info packet was not sent before named_entity_spawn : ignore named_entity_spawn (see #213) + //if (packet.uuid in bot.uuidToUsername) { + // spawn named entity + const runtime_id = packet.runtime_id ?? packet.entity_runtime_id ?? packet.runtime_entity_id; + const entity = fetchEntity(runtime_id); + entity.type = 'player'; + entity.name = 'player'; + entity.id = runtime_id; + entity.username = bot.uuidToUsername[packet.uuid]; + entity.uuid = packet.uuid; + entity.unique_id = packet.unique_entity_id ?? packet.unique_id; + // entity.dataBlobs = packet.metadata + if (bot.supportFeature('fixedPointPosition')) { + entity.position.set(packet.position.x / 32, packet.position.y - NAMED_ENTITY_HEIGHT / 32, packet.position.z / 32); + } else if (bot.supportFeature('doublePosition')) { + entity.position.set(packet.position.x, packet.position.y - NAMED_ENTITY_HEIGHT, packet.position.z); + } else { + entity.position.set(packet.position.x, packet.position.y - NAMED_ENTITY_HEIGHT, packet.position.z); + } + entity.yaw = conv.fromNotchianYawByte(packet.yaw); + entity.pitch = conv.fromNotchianPitchByte(packet.pitch); + entity.headYaw = conv.fromNotchianYawByte(packet.head_yaw ?? 0); + + entity.height = NAMED_ENTITY_HEIGHT; + entity.width = NAMED_ENTITY_WIDTH; + entity.metadata = parseMetadata(packet.metadata, entity.metadata); + if (bot.players[entity.username] !== undefined && !bot.players[entity.username].entity) { + bot.players[entity.username].entity = entity; + } + bot.emit('entitySpawn', entity); + //} + }); + + function setEntityData(entity: any, type: any, entityData?: any) { + if (entityData === undefined) { + entityData = entitiesArray.find((entity: any) => entity.internalId === type); + } + if (entityData) { + entity.displayName = entityData.displayName; + entity.entityType = entityData.id; + entity.name = entityData.name; + entity.kind = entityData.category; + entity.height = entityData.height; + entity.width = entityData.width; + } else { + // unknown entity (item entity?) + entity.type = 'other'; + entity.entityType = type; + entity.displayName = 'unknown'; + entity.name = 'unknown'; + entity.kind = 'unknown'; + } + } + function add_entity(packet: any) { + const entity = fetchEntity(packet.runtime_id ?? packet.runtime_entity_id); + const entityData = bot.registry.entitiesByName[packet.entity_type?.replace('minecraft:', '')]; + + entity.type = entityData ? entityData.type || 'object' : 'object'; + + setEntityData(entity, entity.type, entityData); + + if (packet.item) { + entity.type = 'item'; + entity.item = packet.item; + } + + // if (bot.supportFeature('fixedPointPosition')) { + // entity.position.set(packet.position.x / 32, packet.position.y / 32, packet.position.z / 32) + // } else if (bot.supportFeature('doublePosition')) { + // entity.position.set(packet.position.x, packet.position.y, packet.position.z) + // } + entity.position.set(packet.position.x, packet.position.y, packet.position.z); + entity.velocity.set(packet.velocity.x, packet.velocity.y, packet.velocity.z); + // else if (bot.supportFeature('consolidatedEntitySpawnPacket')) { + // entity.headPitch = conv.fromNotchianPitchByte(packet.headPitch) + // } + + entity.unique_id = packet.entity_id_self ?? packet.unique_id; // 1.19 / 1.18 + if (entity.type !== 'item') { + // NEEDS TO BE MOVED SOMEWHERE + entity.yaw = conv.fromNotchianYawByte(packet.yaw) ?? 0; // conv.fromNotchianYawByte + entity.pitch = conv.fromNotchianPitchByte(packet.pitch) ?? 0; // conv.fromNotchianPitchByte + entity.headYaw = conv.fromNotchianPitchByte(packet.head_yaw) ?? 0; + } + + if (packet.links) { + // Might be wrong + for (const link in packet.links) { + const rider = fetchEntity((packet.links as any)[link].rider_entity_id); + rider.vehicle = fetchEntity((packet.links as any)[link].ridden_entity_id); + //rider.vehicle.position = rider.position + bot.emit('entityAttach', rider, rider.vehicle); + } + } + + //entity.objectData = packet.objectData + bot.emit('update_attributes', packet); + bot.emit('entitySpawn', entity); + } + //Add Item Entity !!! + + bot._client.on('add_entity', add_entity); + bot._client.on('add_item_entity', add_entity); + + bot._client.on('set_entity_motion', (packet) => { + // entity velocity + const entity = fetchEntity(packet.runtime_entity_id); + //console.log(packet.velocity) + entity.velocity = new Vec3(packet.velocity.x, packet.velocity.y, packet.velocity.z); + }); + + bot._client.on('remove_entity', (packet) => { + // destroy entity + const id = packet.entity_id_self; + const entity = fetchEntity(id); + if (!entity) return; // TODO: Fix this + bot.emit('entityGone', entity); + entity.isValid = false; + if (entity.username && bot.players[entity.username]) { + bot.players[entity.username].entity = null; + } + delete bot.entities[id]; + }); + function movePlayer(packet: any) { + // entity teleport + const entity = fetchEntity(packet.runtime_id ?? packet.entity_runtime_id ?? packet.runtime_entity_id); + const position = packet.player_position ?? packet.position; + // if (bot.supportFeature('fixedPointPosition')) { + // entity.position.set(packet.position.x / 32, packet.position.y / 32, packet.position.z / 32) + // } + // if (bot.supportFeature('doublePosition')) { + // entity.position.set(packet.position.x, packet.position.y, packet.position.z) + // } + entity.position.set(position.x, position.y - NAMED_ENTITY_HEIGHT, position.z); // FIND OUT WHY doublePosition NEEDED + // set rotation !!! + entity.yaw = conv.fromNotchianYawByte(packet.yaw ?? packet.rotation.z); + entity.pitch = conv.fromNotchianPitchByte(packet.pitch ?? packet.rotation.x); + entity.headYaw = conv.fromNotchianYawByte(packet.head_yaw ?? 0); + bot.emit('playerMoved', entity); + bot.emit('entityMoved', entity); + } + bot._client.on('start_game', movePlayer); + bot._client.on('move_player', movePlayer); + + bot._client.on('move_entity', (packet) => { + // entity teleport + const entity = fetchEntity(packet.runtime_entity_id); + // if (bot.supportFeature('fixedPointPosition')) { + // entity.position.set(packet.position.x / 32, packet.position.y / 32, packet.position.z / 32) + // } + // if (bot.supportFeature('doublePosition')) { + // entity.position.set(packet.position.x, packet.position.y, packet.position.z) + // } + entity.position.set(packet.position.x, packet.position.y, packet.position.z); // FIND OUT WHY doublePosition NEEDED + entity.yaw = conv.fromNotchianYawByte(packet.yaw ?? packet.rotation.z); + entity.pitch = conv.fromNotchianPitchByte(packet.pitch ?? packet.rotation.x); + entity.headYaw = conv.fromNotchianYawByte(packet.rotation.headYaw ?? 0); + + bot.emit('entityMoved', entity); + }); + + bot._client.on('move_entity_delta', (packet) => { + // entity teleport + const entity = fetchEntity(packet.runtime_entity_id); + // if (bot.supportFeature('fixedPointPosition')) { + // entity.position.set(packet.position.x / 32, packet.position.y / 32, packet.position.z / 32) + // } + // if (bot.supportFeature('doublePosition')) { + // entity.position.set(packet.position.x, packet.position.y, packet.position.z) + // } + entity.position.set(packet.x ?? entity.position.x, packet.y ?? entity.position.y, packet.z ?? entity.position.z); // FIND OUT WHY doublePosition NEEDED + entity.yaw = conv.fromNotchianYawByte(packet.rot_z ?? entity.yaw); + entity.pitch = conv.fromNotchianPitchByte(packet.rot_y ?? entity.pitch); + entity.headYaw = conv.fromNotchianYawByte(packet.rot_x ?? entity.headYaw); + + bot.emit('entityMoved', entity); + }); + + bot._client.on('set_entity_link', (packet) => { + // attach entity + const entity = fetchEntity(packet.link.rider_entity_id); + + if (entity == null) { + bot.logger.warn('entity not found', packet.link.rider_entity_id); + return; + } + if (packet.type === 0) { + const vehicle = entity.vehicle; + delete entity.vehicle; + bot.emit('entityDetach', entity, vehicle); + } else { + entity.vehicle = fetchEntity(packet.link.ridden_entity_id); + // entity.position = entity.vehicle.position + // console.log(entity.position) + bot.emit('entityAttach', entity, entity.vehicle); + } + }); + + bot._client.on('set_entity_data', (packet) => { + // REWRITE TO ADD ENTITY \ PLAYER + //entity metadata + const entity = fetchEntity(packet.runtime_entity_id); + entity.metadata = parseMetadata(packet.metadata, entity.metadata); + //console.log(packet) + bot.emit('entityUpdate', entity); + + // const typeSlot = (bot.supportFeature('itemsAreAlsoBlocks') ? 5 : 6) + (bot.supportFeature('entityMetadataHasLong') ? 1 : 0) + // const slot = packet.metadata.find(e => e.type === typeSlot) + // if (entity.name && (entity.name.toLowerCase() === 'item' || entity.name === 'item_stack') && slot) { + // bot.emit('itemDrop', entity) + // } + // + // const typePose = bot.supportFeature('entityMetadataHasLong') ? 19 : 18 + // const pose = packet.metadata.find(e => e.type === typePose) + // if (pose && pose.value === 2) { + // bot.emit('entitySleep', entity) + // } + // + // const bitField = packet.metadata.find(p => p.key === 0) + // if (bitField === undefined) { + // return + // } + // if ((bitField.value & 2) !== 0) { + // entity.crouching = true + // bot.emit('entityCrouch', entity) + // } else if (entity.crouching) { // prevent the initial entity_metadata packet from firing off an uncrouch event + // entity.crouching = false + // bot.emit('entityUncrouch', entity) + // } + }); + + bot._client.on('update_attributes', (packet) => { + // MAKE COMPATABLE WITH PHYSICS + const entity = fetchEntity(packet.runtime_entity_id); + if (!entity.attributes) entity.attributes = {}; + for (const prop of packet.attributes) { + entity.attributes[prop.name] = { + value: prop.current, + modifiers: prop.modifiers.map((x: any) => ({ ...x, uuid: x.id })), + // extra info, bedrock only + min: prop.min, + max: prop.max, + default: prop.default, + }; + + if (prop.name === 'minecraft:movement') { + entity.attributes[prop.name].value = entity.attributes[prop.name].default; + } + } + bot.emit('entityAttributes', entity); + }); + + bot._client.on('correct_player_move_prediction', (packet) => { + if (packet.prediction_type === 'player') { + if (bot.supportFeature('fixedPointPosition')) { + bot.entity.position.set(packet.position.x / 32, packet.position.y - NAMED_ENTITY_HEIGHT / 32, packet.position.z / 32); + } else if (bot.supportFeature('doublePosition')) { + bot.entity.position.set(packet.position.x, packet.position.y - NAMED_ENTITY_HEIGHT, packet.position.z); + } else { + bot.entity.position.set(packet.position.x, packet.position.y - NAMED_ENTITY_HEIGHT, packet.position.z); + bot.entity.velocity.set(packet.delta.x, packet.delta.y, packet.delta.z); + } + } + }); + + bot.on('spawn', () => { + bot.emit('entitySpawn', bot.entity); + }); + + bot._client.on('player_list', (packet) => { + // REWRITE + // player list item(s) + // if (bot.supportFeature('playerInfoActionIsBitfield')) { + // for (const item of packet.records) { + // console.log(item) + // if (item.type === 'remove'){ + // + // } + // let player = bot.uuidToUsername[item.uuid] ? bot.players[bot.uuidToUsername[item.uuid]] : null + // let newPlayer = false + // + // const obj = { + // uuid: item.uuid + // } + // + // if (!player) newPlayer = true + // + // player = player || obj + // + // if (packet.action & 1) { + // obj.username = item.player.name + // obj.displayName = player.displayName || new ChatMessage({ text: '', extra: [{ text: item.player.name }] }) + // } + // + // if (packet.action & 4) { + // obj.gamemode = item.gamemode + // } + // + // if (packet.action & 16) { + // obj.ping = item.latency + // } + // + // if (item.displayName) { + // obj.displayName = new ChatMessage(JSON.parse(item.displayName)) + // } else if (packet.action & 32) obj.displayName = new ChatMessage({ text: '', extra: [{ text: player.username || obj.username }] }) + // + // if (newPlayer) { + // if (!obj.username) continue // Should be unreachable + // player = bot.players[obj.username] = obj + // bot.uuidToUsername[obj.uuid] = obj.username + // } else { + // Object.assign(player, obj) + // } + // + // const playerEntity = Object.values(bot.entities).find(e => e.type === 'player' && e.username === player.username) + // player.entity = playerEntity + // + // if (playerEntity === bot.entity) { + // bot.player = player + // } + // + // if (newPlayer) { + // bot.emit('playerJoined', player) + // } else { + // bot.emit('playerUpdated', player) + // } + // } + // } else { + packet.records.records.forEach((item: any) => { + let player = bot.uuidToUsername[item.uuid] ? bot.players[bot.uuidToUsername[item.uuid]] : null; + + if (packet.records.type === 'add') { + let newPlayer = false; + + // New Player + if (!player) { + if (!item.username) return; + player = bot.players[item.username] = { + username: item.username, + uuid: item.uuid, + displayName: new ChatMessage({ text: '', extra: [{ text: item.username }] }), + profileKeys: item.xbox_user_id ?? null, + }; + + bot.uuidToUsername[item.uuid] = item.username; + bot.emit('playerJoined', player); + newPlayer = true; + } else { + // Just an Update + player = bot.players[item.username] = { + username: item.username, + uuid: item.uuid, + displayName: new ChatMessage({ text: '', extra: [{ text: item.username }] }), + profileKeys: item.xbox_user_id ?? null, + }; + } + + // if (item.username) { + // player.username = new ChatMessage(item.username) + // } + const playerEntity = Object.values(bot.entities).find((e: any) => e.type === 'player' && e.uuid === item.uuid) as any; + player.entity = playerEntity; + if (player.entity) + bot.players[item.username]['displayName'] = new ChatMessage({ + text: '', + extra: [{ text: player.entity.nametag }], + }); + + if (playerEntity === bot.entity) { + bot.player = player; + } + + if (!newPlayer) { + bot.emit('playerUpdated', player); + } + } else if (packet.records.type === 'remove') { + if (!player) return; + if (player.entity === bot.entity) return; + + // delete entity + if (player.entity) { + const id = player.entity.id; + const entity = fetchEntity(id); + bot.emit('entityGone', entity); + entity.isValid = false; + player.entity = null; + delete bot.entities[id]; + } + + delete bot.players[player.username]; + delete bot.uuidToUsername[item.uuid]; + bot.emit('playerLeft', player); + return; + } else { + return; + } + bot.emit('playerUpdated', player); + }); + }); + + function swingArm(arm = 'right', showHand = true, swingSource?: 'mine' | 'build') { + //const hand = arm === 'right' ? 0 : 1 + const packet: any = { + action_id: 'swing_arm', + runtime_entity_id: bot.entity.id, + data: 0, + has_swing_source: !!swingSource, + }; + if (swingSource) { + packet.swing_source = swingSource; + } + bot._client.write('animate', packet); + } + + bot.swingArm = swingArm; + bot.attack = attack; + // bot.mount = mount + // bot.dismount = dismount + // bot.useOn = useOn + // bot.moveVehicle = moveVehicle + // useEntity + function attackEntity(target: any) { + itemUseOnEntity(target, 0); + } + function itemUseOnEntity(target: any, type: number) { + const typeStr = ['attack', 'interact'][type]; + const transaction = { + transaction: { + legacy: { + legacy_request_id: 0, + }, + transaction_type: 'item_use_on_entity', + actions: [], + transaction_data: { + entity_runtime_id: target.id, + action_type: typeStr, + hotbar_slot: bot.quickBarSlot, + held_item: bot.heldItem, + player_pos: bot.entity.position, + click_pos: { + // in case with interact its pos on hitbox of entity that is being interacted with + x: 0, + y: 0, + z: 0, + }, + }, + }, + }; + bot._client.write('inventory_transaction', transaction); + } + + function attack(target: any, swing = true) { + // arm animation comes before the use_entity packet on 1.8 + if (bot.supportFeature('armAnimationBeforeUse')) { + if (swing) { + bot.swingArm(); // in inventory + } + attackEntity(target); + } else { + attackEntity(target); + if (swing) { + bot.swingArm(); // in inventory + } + } + } + + function fetchEntity(id: any) { + function searchByUUID(obj: any, unique_id: any) { + for (const key in obj) { + if (obj[key].unique_id === unique_id) { + return obj[key]; + } + } + return null; // Если нет совпадений + } + if (id < 0) { + let entity = searchByUUID(bot.entities, id); + if (entity) { + return entity; + } else { + // delete bot.entities[id] + // throw Error('UNEXPECTED!!! Couldn\'t find entity!') + return null; + } + } + + return bot.entities[id] || (bot.entities[id] = new Entity(id)); + } + + // Expose fetchEntity on bot for other plugins (like bossbar) + (bot as any).fetchEntity = fetchEntity; +} + +function parseMetadata(metadata: any, entityMetadata: any = {}) { + if (metadata !== undefined) { + for (const { key, value } of metadata) { + entityMetadata[key] = value; + } + } + + return entityMetadata; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/experience.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/experience.mts new file mode 100644 index 0000000..25b2591 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/experience.mts @@ -0,0 +1,23 @@ +import type { BedrockBot } from '../../index.js'; + +export default function inject(bot: BedrockBot) { + bot.experience = { + level: null, + points: null, + progress: null, + }; + bot.on('entityAttributes', (entity) => { + if (entity !== bot.entity) return; + if (!entity.attributes) return; + if ('minecraft:player.level' in entity.attributes) { + bot.experience.level = entity.attributes['minecraft:player.level'].value; + } + if ('minecraft:player.experience' in entity.attributes) { + let attribute = entity.attributes['minecraft:player.experience']; + bot.experience.points = attribute.value; + // something wrong here ! + bot.experience.progress = ((attribute.value - attribute.default) / (attribute.max - attribute.default)) * 100; + } + bot.emit('experience'); + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/fishing.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/fishing.mts new file mode 100644 index 0000000..bc00d95 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/fishing.mts @@ -0,0 +1,91 @@ +import type { Bot } from '../..' +import { createDoneTask, createTask } from '../promise_utils.js' + +export default function inject(bot: Bot) { + let fishingTask = createDoneTask() + let lastBobber: any = null + + // In Bedrock, the fishing bobber is called "minecraft:fishing_hook" + const FISHING_HOOK_TYPE = 'minecraft:fishing_hook' + + // Handle fishing hook entity spawn + bot._client.on('add_entity', (packet: { + entity_type: string + runtime_id: number | bigint + unique_id?: number | bigint + }) => { + if (packet.entity_type === FISHING_HOOK_TYPE && !fishingTask.done && !lastBobber) { + // Store the bobber entity reference + const entityId = typeof packet.runtime_id === 'bigint' + ? Number(packet.runtime_id) + : packet.runtime_id + lastBobber = bot.entities[entityId] + + // If entity isn't immediately available, wait for it + if (!lastBobber) { + // Store the ID to track it later + lastBobber = { id: entityId, _pending: true } + } + } + }) + + // Handle entity events for fishing hook + // In Bedrock, fish_hook_hook event indicates a fish is hooked + bot._client.on('entity_event', (packet: { + runtime_entity_id: number | bigint + event_id: string + }) => { + if (!lastBobber || fishingTask.done) return + + // When fish_hook_hook event fires, a fish is on the hook! + if (packet.event_id === 'fish_hook_hook') { + const entityId = typeof packet.runtime_entity_id === 'bigint' + ? Number(packet.runtime_entity_id) + : packet.runtime_entity_id + + const bobberId = lastBobber._pending ? lastBobber.id : lastBobber.id + if (entityId === bobberId) { + // Reel in the fish! + bot.activateItem() + lastBobber = null + fishingTask.finish() + } + } + }) + + // Handle bobber entity removal + bot._client.on('remove_entity', (packet: { + entity_id_self: number | bigint + }) => { + if (!lastBobber) return + + const removedId = typeof packet.entity_id_self === 'bigint' + ? Number(packet.entity_id_self) + : packet.entity_id_self + + const bobberId = lastBobber._pending ? lastBobber.id : lastBobber.id + if (removedId === bobberId) { + lastBobber = null + if (!fishingTask.done) { + fishingTask.cancel(new Error('Fishing cancelled')) + } + } + }) + + async function fish(): Promise { + if (!fishingTask.done) { + fishingTask.cancel(new Error('Fishing cancelled due to calling bot.fish() again')) + } + + fishingTask = createTask() + lastBobber = null + + // Cast the fishing rod + bot.activateItem() + + // Wait for fish to bite + await fishingTask.promise + } + + bot.fish = fish +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/game.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/game.mts new file mode 100644 index 0000000..04ab69e --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/game.mts @@ -0,0 +1,172 @@ +import type { BedrockBot } from '../../index.js'; +import { createRequire } from 'module'; +import { createTask } from '../promise_utils.js'; +const require = createRequire(import.meta.url); + +const nbt = require('prismarine-nbt'); + +const difficultyNames = ['peaceful', 'easy', 'normal', 'hard']; +//const gameModes = ['survival', 'creative', 'adventure'] + +// const dimensionNames = { +// '-1': 'minecraft:nether', +// 0: 'minecraft:overworld', +// 1: 'minecraft:end' +// } + +// const parseGameMode = gameModeBits => gameModes[(gameModeBits & 0b11)] // lower two bits + +interface GameOptions { + brand?: string; +} + +export default function inject(bot: BedrockBot, options: GameOptions = {}) { + // function getBrandCustomChannelName () { + // if (bot.supportFeature('customChannelMCPrefixed')) { + // return 'MC|Brand' + // } else if (bot.supportFeature('customChannelIdentifier')) { + // return 'minecraft:brand' + // } + // throw new Error('Unsupported brand channel name') + // } + + function handleItemRegistryPacketData(packet: any) { + if (bot.registry.handleItemRegistry) { + bot.registry.handleItemRegistry(packet); + (bot as any).item_registry_task.finish(); + (bot as any).item_registry_task = null; + } + } + + function handleStartGamePacketData(packet: any) { + bot.game.levelType = packet.generator ?? (packet.generator === 2 ? 'flat' : 'default'); + bot.game.hardcore = packet.player_gamemode === 'hardcore'; + bot.game.gameMode = packet.player_gamemode; + + bot.game.dimension = packet.dimension; + + bot.registry.handleStartGame(packet); + if (packet.itemStates) { + (bot as any).item_registry_task.finish(); + (bot as any).item_registry_task = null; + } + + bot._client.queue('serverbound_loading_screen', { + type: 1, + }); + bot._client.queue('serverbound_loading_screen', { + type: 2, + }); + bot._client.queue('interact', { + action_id: 'mouse_over_entity', + target_entity_id: 0n, + position: { + x: 0, + y: 0, + z: 0, + }, + }); + bot._client.queue('set_local_player_as_initialized', { + runtime_entity_id: `${bot.entity.id}`, + }); + + // CODE BELOW MIGHT BE WRONG + // if (bot.supportFeature('dimensionIsAnInt')) { + // bot.game.dimension = dimensionNames[packet.dimension] + // } else if (bot.supportFeature('dimensionIsAString')) { + // bot.game.dimension = packet.dimension + // } else if (bot.supportFeature('dimensionIsAWorld')) { + // bot.game.dimension = packet.worldName + // } else { + // throw new Error('Unsupported dimension type in start_game packet') + // } + + // if (packet.dimensionCodec) { + // bot.registry.loadDimensionCodec(packet.dimensionCodec) + // } + // CODE BELOW MIGHT BE WRONG FOR BEDROCK + // if (bot.supportFeature('dimensionDataInCodec')) { // 1.19+ + // if (packet.world_gamemode) { // login + // bot.game.dimension = packet.worldType.replace('minecraft:', '') + // const { minY, height } = bot.registry.dimensionsByName[bot.game.dimension] + // bot.game.minY = minY + // bot.game.height = height + // } else if (packet.dimension) { // respawn + // bot.game.dimension = packet.dimension.replace('minecraft:', '') + // } + // } else if (bot.supportFeature('dimensionDataIsAvailable')) { // 1.18 + //console.log(bot.registry.dimensionsByName) + //const { minY, height } = bot.registry.dimensionsByName[bot.game.dimension] + // CODE BELOW SHOULD BE OPTIMIZED FOR BEDROCK + if (bot.registry.dimensionsByName) { + const { minY, height } = bot.registry.dimensionsByName[bot.game.dimension]; + bot.game.minY = minY; + bot.game.height = height; + } else { + // depends on game version + bot.game.minY = -64; + bot.game.height = 384; + } + if (packet.difficulty) { + bot.game.difficulty = difficultyNames[packet.difficulty]; + } + } + + bot.game = {} as any; + (bot as any).item_registry_task = createTask(); + + // const brandChannel = getBrandCustomChannelName() + // bot._client.registerChannel(brandChannel, ['string', []]) + + bot._client.on('start_game', (packet) => { + handleStartGamePacketData(packet); + + // bot.game.maxPlayers = packet.maxPlayers + // if (packet.enableRespawnScreen) { + // bot.game.enableRespawnScreen = packet.enableRespawnScreen + // } + // if (packet.viewDistance) { + // bot.game.serverViewDistance = packet.viewDistance + // } + + bot.emit('login'); + bot.emit('game'); + + // varint length-prefixed string as data + //bot._client.writeChannel(brandChannel, options.brand) + }); + + bot._client.on('item_registry', (packet) => { + handleItemRegistryPacketData(packet); + }); + + bot._client.on('respawn', (packet) => { + //handleRespawnPacketData(packet) + bot.emit('game'); + }); + + // bot._client.on('game_state_change', (packet) => { + // if (packet?.reason === 4 && packet?.gameMode === 1) { + // bot._client.write('client_command', { action: 0 }) + // } + // if (packet.reason === 3) { + // bot.game.gameMode = parseGameMode(packet.gameMode) + // bot.emit('game') + // } + // }) + + // bot._client.on('difficulty', (packet) => { + // bot.game.difficulty = difficultyNames[packet.difficulty] + // }) + + // bot._client.on(brandChannel, (serverBrand) => { + // bot.game.serverBrand = serverBrand + // }) + + // mimic the vanilla 1.17 client to prevent anticheat kicks + // bot._client.on('ping', (data) => { + // bot._client.write('pong', { + // id: data.id + // }) + // }) +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/health.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/health.mts new file mode 100644 index 0000000..29b1580 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/health.mts @@ -0,0 +1,131 @@ +import type { BedrockBot } from '../../index.js'; +import { Vec3 } from 'vec3'; + +interface HealthOptions { + respawn?: boolean; +} + +export default function inject(bot: BedrockBot, options: HealthOptions = {}) { + bot.isAlive = true; + + // undocumented, bedrock-specific fields + (bot as any).respawnLocked = true; // lock respawn before player initialized + (bot as any).awaitingRespawn = false; // used to prevent double events + (bot as any).respawnQueued = false; // used to prevent sending multiple respawn packets + (bot as any).spawned = false; // keep track of spawned state + (bot as any).deathHandled = false; + + bot._client.on('respawn', (packet) => { + if (packet.state === 0 && !(bot as any).awaitingRespawn && !(bot as any).respawnLocked) { + // respawn avaliable + (bot as any).awaitingRespawn = true; + bot.emit('respawn'); + } + if (packet.state === 1) { + // ready state + bot.entity.position = new Vec3(packet.position.x, packet.position.y, packet.position.z); + (bot as any).respawnQueued = false; + } + }); + + bot._client.on('play_status', (packet) => { + // after login + if (packet.status === 'player_spawn') { + (bot as any).respawnLocked = false; + handleSpawn(); + bot.emit('entitySpawn', bot.entity); + } + }); + + bot._client.on('entity_event', (packet) => { + // after respawn button press + if (packet.runtime_entity_id !== bot.entity.id) return; + if (packet.event_id === 'death_animation') { + handleDeath(); + } + if (packet.event_id === 'respawn') { + handleSpawn(); + } + }); + + bot._client.on('set_health', (packet) => { + bot.health = packet.health; + bot.food = 20; + bot.foodSaturation = 5; + if (packet.health > 0) { + handleSpawn(); + } + bot.emit('health'); + }); + + bot.on('entityAttributes', (entity) => { + if (entity !== bot.entity) return; + if (!entity.attributes) return; + + if ('minecraft:player.hunger' in entity.attributes) { + bot.food = entity.attributes['minecraft:player.hunger'].value; + } + + if ('minecraft:player.saturation' in entity.attributes) { + bot.foodSaturation = entity.attributes['minecraft:player.saturation'].value; + } + + let health_changed = false; + + if ('minecraft:health' in entity.attributes) { + health_changed = bot.health !== entity.attributes['minecraft:health'].value; + bot.health = entity.attributes['minecraft:health'].value; + } + + if (health_changed) { + bot.emit('health'); + } + + if (bot.health <= 0) { + if (bot.isAlive) { + handleDeath(); + if (options.respawn) { + respawn(); + } + } + } else if (bot.health > 0 && !bot.isAlive) { + handleSpawn(); + } + }); + + function handleSpawn() { + if (!(bot as any).spawned && ((bot as any).awaitingRespawn || (bot as any).respawnLocked)) { + (bot as any).awaitingRespawn = false; + bot.isAlive = true; + (bot as any).spawned = true; + (bot as any).deathHandled = false; + bot.emit('spawn'); + } + } + + function handleDeath() { + if (!(bot as any).deathHandled) { + bot.isAlive = false; + (bot as any).spawned = false; + (bot as any).deathHandled = true; + bot.emit('death'); + } + } + + const respawn = () => { + if (bot.isAlive) return; + if ((bot as any).respawnQueued) return; + bot._client.write('respawn', { + position: { + x: 0, + y: 0, + z: 0, + }, + state: 2, + runtime_entity_id: bot.entity.id, + }); + (bot as any).respawnQueued = true; + }; + + bot.respawn = respawn; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/input-data-service.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/input-data-service.mts new file mode 100644 index 0000000..b1c2d59 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/input-data-service.mts @@ -0,0 +1,103 @@ +interface ControlState { + forward: boolean; + back: boolean; + left: boolean; + right: boolean; + jump: boolean; + sprint: boolean; + sneak: boolean; +} + +interface InputData { + down_left: boolean; + down_right: boolean; + down: boolean; + up: boolean; + left: boolean; + right: boolean; + up_left: boolean; + up_right: boolean; + sprint_down: boolean; + sprinting: boolean; + start_sprinting: boolean; + stop_sprinting: boolean; +} + +export class InputDataService { + #data: InputData = { + down_left: false, + down_right: false, + down: false, + up: false, + left: false, + right: false, + up_left: false, + up_right: false, + sprint_down: false, + sprinting: false, + start_sprinting: false, + stop_sprinting: false, + }; + + #prevControlState: ControlState = { + forward: false, + back: false, + left: false, + right: false, + jump: false, + sprint: false, + sneak: false, + }; + + update(controlState: ControlState) { + const prevData = { ...this.#data }; + + this.#data.up = controlState.forward; + this.#data.down = controlState.back; + this.#data.right = controlState.right; + this.#data.left = controlState.left; + this.#data.up_right = controlState.forward && controlState.right; + this.#data.up_left = controlState.forward && controlState.left; + + if (this.#prevControlState.sprint !== controlState.sprint) { + this.#data.sprint_down = controlState.sprint; + this.#data.sprinting = controlState.sprint; + } + + this.#data.start_sprinting = !this.#isMoving(this.#prevControlState) && this.#isMoving(controlState); + this.#data.stop_sprinting = this.#isMoving(this.#prevControlState) && !this.#isMoving(controlState); + + this.#prevControlState = { ...controlState }; + return { + diff: this.#diff(this.#data, prevData), + }; + } + + #isMoving(controlState: ControlState) { + return (controlState.forward && !controlState.back) || (controlState.back && !controlState.forward) || (controlState.left && !controlState.right) || (controlState.right && !controlState.left); + } + + #diff(obj1: Record, obj2: Record): Record { + var result: Record = {}; + var change; + for (var key in obj1) { + if (typeof obj2[key] == 'object' && typeof obj1[key] == 'object') { + change = this.#diff(obj1[key], obj2[key]); + if (this.#isEmptyObject(change) === false) { + result[key] = change; + } + } else if (obj2[key] != obj1[key]) { + result[key] = obj1[key] ?? obj2[key]; + } + } + return result; + } + + #isEmptyObject(obj: Record) { + var name; + for (name in obj) { + return false; + } + return true; + } +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/inventory.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/inventory.mts new file mode 100644 index 0000000..4586b27 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/inventory.mts @@ -0,0 +1,1635 @@ +import type { Block } from 'prismarine-block'; +import type { BedrockBot, TransferOptions } from '../../index.js'; +import type { Vec3 } from 'vec3'; +import type { Entity } from 'prismarine-entity'; +import windowLoader, { type Window, type WindowsExports } from 'prismarine-windows'; +import type { EventEmitter } from 'events'; +import itemLoader, { type Item } from 'prismarine-item'; +import assert from 'assert'; + +// Import shared utilities from lib/bedrock/ +import { + getNextItemStackRequestId, + getStackId, + SlotRanges, + getContainerForCursorOp, + actions, + sendRequest, + ContainerIds, + cursor, + slot as makeSlot, + waitForResponse as waitForResponseShared, + depositToContainer as sharedDeposit, + withdrawFromContainer as sharedWithdraw, + // Note: getSlotIndex, getWindow, getContainerFromSlot are defined locally + // because they need bot/window context that the shared versions don't have +} from '../bedrock/index.mts'; + +// Re-export for backwards compatibility with craft.mts +export { getNextItemStackRequestId }; + +const QUICK_BAR_COUNT = 9; +// Bedrock slot layout: 0-8 hotbar, 9-35 inventory, 36-39 armor, 45 offhand +const QUICK_BAR_START = 0; + +// Track pending inventory updates for sync +let lastInventoryContentTime = 0; + +export default function inject(bot: BedrockBot) { + // Expose request ID getter on bot for other plugins (e.g., digging) + bot.getNextItemStackRequestId = getNextItemStackRequestId; + + bot.activateBlock = activateBlock; + bot.activateEntity = activateEntity; + bot.activateEntityAt = activateEntityAt; + bot.placeBlock = placeBlock; + bot.placeEntity = placeEntity; + bot.consume = consume; + bot.activateItem = activateItem; + bot.deactivateItem = deactivateItem; + + // not really in the public API + bot.clickWindow = clickWindow; + bot.putSelectedItemRange = putSelectedItemRange; + bot.putAway = putAway; + bot.closeWindow = closeWindow; + bot.transfer = transfer; + bot.openBlock = openBlock; + bot.openEntity = openEntity; + bot.moveSlotItem = moveSlotItem; + bot.updateHeldItem = updateHeldItem; + bot.openInventory = openInventory; + + const Item = (itemLoader as any)(bot.registry) as typeof Item; + const windows = (windowLoader as any)(bot.registry) as WindowsExports; + // prismarine-windows now automatically uses Bedrock slot layouts when registry.type === 'bedrock' + + bot.quickBarSlot = null; + bot.inventory = windows.createWindow(0, 'minecraft:inventory', 'Inventory'); + bot.inventory.hotbarStart = 0; // first 9 slots are crafting grid + + // Ensure we have 46 slots to include offhand at slot 45 + // (Bedrock registry doesn't declare 'shieldSlot' feature, so prismarine-windows only creates 45 slots) + while (bot.inventory.slots.length < 46) { + bot.inventory.slots.push(null); + } + + bot.currentWindow = null; + bot.heldItem = null; + bot.usingHeldItem = false; + + Object.defineProperty(bot, 'heldItem', { + get: function () { + return bot.inventory.slots[QUICK_BAR_START + bot.quickBarSlot]; + }, + }); + + bot.on('heldItemChanged', (heldItem: Item | null) => {}); + + bot._client.on('inventory_slot', (packet: protocolTypes.packet_inventory_slot) => { + if (bot.item_registry_task) { + //bot.item_registry_task.promise.then(handle); + } else { + handle(); + } + function handle() { + let window = getWindow(packet.window_id); + if (!window) return; + const newItem = Item.fromNotch(packet.item); + // Preserve stack_id from Bedrock protocol + if (newItem && packet.item.stack_id !== undefined) { + (newItem as any).stackId = packet.item.stack_id; + } + const slotIndex = getSlotIndex(packet.window_id, packet.slot); + //console.log("update window", packet.window_id, packet.slot, newItem); + window.updateSlot(slotIndex, newItem); + updateHeldItem(); + } + }); + + bot._client.on('inventory_transaction', (packet: protocolTypes.packet_inventory_transaction) => { + const transaction = packet.transaction; + if (bot.item_registry_task) { + //bot.item_registry_task.promise.then(handle); + } else { + handle(); + } + function handle() { + for (const action of transaction.actions) { + if (action.source_type === 'container') { + let window = getWindow(action.inventory_id); + if (!window) continue; // Skip if window not found (e.g., 'ui' type) + const newItem = Item.fromNotch(action.new_item); + // Preserve stack_id from Bedrock protocol + if (newItem && action.new_item.stack_id !== undefined) { + (newItem as any).stackId = action.new_item.stack_id; + } + const slotIndex = getSlotIndex(action.inventory_id, action.slot); + window.updateSlot(slotIndex, newItem); + updateHeldItem(); + + // console.log( + // "update window", + // action.inventory_id, + // slotIndex, + // newItem + // ); + } else if (action.source_type === 'world_interaction' || action.source_type === 'creative') { + } else { + assert(false); + } + } + } + }); + bot._client.on('inventory_content', (packet: protocolTypes.packet_inventory_content) => { + const window = bot.currentWindow?.id == packet.window_id ? bot.currentWindow : getWindow(packet.window_id); + if (!window) return; + + if (packet.window_id === 'inventory') { + for (let i = 0; i < packet.input.length; i++) { + const inputItem = packet.input[i]; + const newItem = Item.fromNotch(inputItem); + // Preserve stack_id from Bedrock protocol + if (newItem && inputItem.stack_id !== undefined) { + (newItem as any).stackId = inputItem.stack_id; + } + //const slotIndex = getSlotIndex(packet.window_id === 'inventory' && i <=8 ? 'hotbar':'inventory', i); + window.updateSlot(i, newItem); + //console.log("update window", packet.window_id, i, newItem); + } + } else { + // For container windows like chests + for (let i = 0; i < packet.input.length; i++) { + const inputItem = packet.input[i]; + const newItem = Item.fromNotch(inputItem); + // Preserve stack_id from Bedrock protocol + if (newItem && inputItem.stack_id !== undefined) { + (newItem as any).stackId = inputItem.stack_id; + } + const slotIndex = getSlotIndex(packet.window_id, i); + window.updateSlot(slotIndex, newItem); + } + } + + // Update held item reference + updateHeldItem(); + + // Track inventory content time for sync + if (packet.window_id === 'inventory') { + lastInventoryContentTime = Date.now(); + bot.emit('inventorySync'); + } + + // Emit event to signal window items have been set + bot.emit(`setWindowItems:${window.id}`); + }); + + // Wait for next inventory_content packet (with timeout) + function waitForInventorySync(timeout: number = 200): Promise { + return new Promise((resolve) => { + const startTime = Date.now(); + + // If we recently received inventory_content, resolve immediately + if (Date.now() - lastInventoryContentTime < 50) { + resolve(); + return; + } + + const handler = () => { + cleanup(); + resolve(); + }; + + const timer = setTimeout(() => { + cleanup(); + resolve(); // Timeout - proceed anyway + }, timeout); + + const cleanup = () => { + bot.removeListener('inventorySync', handler); + clearTimeout(timer); + }; + + bot.once('inventorySync', handler); + }); + } + + bot._client.on('player_hotbar', (packet: protocolTypes.packet_player_hotbar) => { + // Update the selected hotbar slot + // This is sent by the server when the player changes their selected hotbar slot + if (packet.select_slot) { + const slot = packet.selected_slot; + + // Validate slot is within hotbar range (0-8) + if (slot >= 0 && slot < 9) { + bot.quickBarSlot = slot; + updateHeldItem(); + } + } + }); + + bot._client.on('play_status', (packet: protocolTypes.packet_play_status) => { + if (packet.status === 'player_spawn') { + // After receiving player_spawn, we need to send 2 mob_equipment packets + // 1. For the active hotbar item + // 2. For the offhand item + + // Default to slot 0 if quickBarSlot is not set yet + const selectedSlot = getSlotIndex('inventory', bot.quickBarSlot ?? 0); + + // Send mob_equipment for active hotbar item + const hotbarItem = bot.inventory.slots[selectedSlot]; + bot._client.write('mob_equipment', { + runtime_entity_id: bot.entity.id, + item: hotbarItem ? Item.toNotch(hotbarItem, 0) : { network_id: 0 }, + slot: selectedSlot, + selected_slot: selectedSlot, + window_id: 'inventory', + }); + + // Send mob_equipment for offhand item + const offhandItem = bot.inventory.slots[45]; // offhand slot + bot._client.write('mob_equipment', { + runtime_entity_id: bot.entity.id, + item: offhandItem ? Item.toNotch(offhandItem, 0) : { network_id: 0 }, + slot: 1, + selected_slot: 0, + window_id: 'offhand', + }); + } + }); + + bot._client.on('container_open', (packet: protocolTypes.packet_container_open) => { + // Special case: when opening player's own inventory, use bot.inventory as currentWindow + if (packet.window_type === 'inventory') { + bot.currentWindow = bot.inventory; + bot.currentWindow.id = packet.window_id; + // Inventory is already populated, emit windowOpen immediately + bot.emit('windowOpen', bot.currentWindow); + return; + } + + // Map Bedrock window types to prismarine-windows compatible types and slot counts + // Slot counts are for the container portion only (not including player inventory) + const windowTypeMap: Record = { + container: { type: 'minecraft:generic_9x3', slots: 27 }, // Single chest (3 rows of 9) + double_chest: { type: 'minecraft:generic_9x6', slots: 54 }, // Double chest (6 rows of 9) + workbench: { type: 'minecraft:crafting_table', slots: 10 }, // 9 craft grid + 1 output + furnace: { type: 'minecraft:furnace', slots: 3 }, // Input, fuel, output + enchantment: { type: 'minecraft:enchanting_table', slots: 2 }, // Item + lapis + brewing_stand: { type: 'minecraft:brewing_stand', slots: 5 }, // 3 bottles + blaze + ingredient + anvil: { type: 'minecraft:anvil', slots: 3 }, // 2 input + 1 output + dispenser: { type: 'minecraft:dispenser', slots: 9 }, // 3x3 grid + dropper: { type: 'minecraft:dropper', slots: 9 }, // 3x3 grid + hopper: { type: 'minecraft:hopper', slots: 5 }, // 5 slots + beacon: { type: 'minecraft:beacon', slots: 1 }, // 1 payment slot + loom: { type: 'minecraft:loom', slots: 4 }, // Banner + dye + pattern + output + grindstone: { type: 'minecraft:grindstone', slots: 3 }, // 2 input + 1 output + blast_furnace: { type: 'minecraft:blast_furnace', slots: 3 }, // Same as furnace + smoker: { type: 'minecraft:smoker', slots: 3 }, // Same as furnace + stonecutter: { type: 'minecraft:stonecutter', slots: 2 }, // Input + output + horse: { type: 'EntityHorse', slots: 2 }, // Saddle + armor (varies by horse type) + shulker_box: { type: 'minecraft:shulker_box', slots: 27 }, // 27 slots like single chest + }; + + const windowInfo = windowTypeMap[packet.window_type] || { type: 'minecraft:generic_9x3', slots: 27 }; + + // Create a new window for this container with explicit slot count + const newWindow = windows.createWindow( + packet.window_id, + windowInfo.type, + packet.window_type, // Use window_type as title for now + windowInfo.slots // Provide slot count for proper window creation + ); + + if (!newWindow) { + console.warn(`Failed to create window for type: ${packet.window_type} (mapped to: ${windowInfo.type})`); + return; + } + + bot.currentWindow = newWindow; + + // Window types that start empty and won't receive inventory_content + const emptyWindowTypes = ['workbench', 'anvil', 'enchantment', 'grindstone', 'stonecutter', 'loom']; + + if (emptyWindowTypes.includes(packet.window_type)) { + // These windows start empty, emit windowOpen immediately + addContainerMethods(newWindow); + bot.emit('windowOpen', newWindow); + } else { + // Wait for inventory_content packet to populate the window before emitting windowOpen + bot.once(`setWindowItems:${newWindow.id}`, () => { + // Add container helper methods to the window + addContainerMethods(newWindow); + bot.emit('windowOpen', newWindow); + }); + } + }); + + /** + * Add withdraw/deposit/close helper methods to a container window + */ + function addContainerMethods(window: Window): void { + // Don't add to player inventory + if (window === bot.inventory) return; + + /** + * Withdraw items from container to player inventory + * In Bedrock, player inventory is separate from container window + */ + (window as any).withdraw = async (itemType: number, metadata: number | null, count: number | null, nbt?: object | null): Promise => { + const containerSlots = window.inventoryStart !== undefined ? window.inventoryStart : 27; + await sharedWithdraw(bot, window, itemType, metadata, count, containerSlots, nbt); + }; + + /** + * Deposit items from player inventory to container + * In Bedrock, player inventory is separate from container window + */ + (window as any).deposit = async (itemType: number, metadata: number | null, count: number | null, nbt?: object | null): Promise => { + const containerSlots = window.inventoryStart !== undefined ? window.inventoryStart : 27; + await sharedDeposit(bot, window, itemType, metadata, count, containerSlots, nbt); + }; + + /** + * Close this container window + */ + (window as any).close = (): void => { + bot.closeWindow(window); + }; + } + + bot._client.on('item_stack_response', (packet: protocolTypes.packet_item_stack_response) => { + // Process each response in the packet + for (const response of packet.responses) { + const { status, request_id, containers } = response; + + if (status === 'ok') { + for (const container of containers) { + const containerId = container.slot_type?.container_id; + + // Skip cursor updates - cursor is temporary and not a real inventory slot + if (containerId === 'cursor') continue; + + // Determine which window to update based on containerId + let window: Window | null; + if (containerId === 'container') { + // Container slots belong to the open container window + window = bot.currentWindow; + } else if (containerId === 'inventory' || containerId === 'hotbar' || containerId === 'armor' || containerId === 'offhand' || containerId === 'hotbar_and_inventory') { + // Player inventory slots + window = bot.inventory; + } else { + // Unknown container, skip + continue; + } + + if (!window) continue; + + if (container.slots) { + for (const slotData of container.slots) { + // Map slot index based on containerId + const slotIndex = getSlotIndex(containerId as protocolTypes.WindowID, slotData.slot); + + if (slotData.item_stack_id === 0 || slotData.count === 0) { + window.updateSlot(slotIndex, null); + } else { + // Update the stack_id and count of existing item if present + const existingItem = window.slots[slotIndex]; + if (existingItem) { + existingItem.count = slotData.count; + (existingItem as any).stackId = slotData.item_stack_id; + } + } + } + } + } + + // Emit success event for this request + bot.emit(`itemStackResponse:${request_id}`, true); + } else { + // Transaction was rejected by the server + bot.emit(`itemStackResponse:${request_id}`, false); + } + + updateHeldItem(); + } + }); + + bot._client.on('container_close', (packet: protocolTypes.packet_container_close) => { + // Close the container window + const oldWindow = bot.currentWindow; + + if (oldWindow && oldWindow.id === packet.window_id) { + bot.currentWindow = null; + bot.emit('windowClose', oldWindow); + } + }); + + //////////////////////////////////////////////////////////////// + + async function activateBlock(block: Block, direction?: Vec3, cursorPos?: Vec3): Promise { + // Wait for any pending inventory updates from previous operations + // This prevents race conditions where item pickups haven't been processed yet + await waitForInventorySync(200); + + // Calculate face direction based on bot position relative to block + let face: number; + if (direction) { + face = getFaceFromDirection(direction); + } else { + // Auto-calculate face based on bot position + const dx = bot.entity.position.x - block.position.x - 0.5; + const dy = bot.entity.position.y - block.position.y - 0.5; + const dz = bot.entity.position.z - block.position.z - 0.5; + const ax = Math.abs(dx), + ay = Math.abs(dy), + az = Math.abs(dz); + + if (ax >= ay && ax >= az) { + face = dx > 0 ? 5 : 4; // east or west + } else if (ay >= ax && ay >= az) { + face = dy > 0 ? 1 : 0; // top or bottom + } else { + face = dz > 0 ? 3 : 2; // south or north + } + } + + // Default click position (center of face) + const clickPos = cursorPos || { x: 0.5, y: 0.5, z: 0.5 }; + await bot.lookAt(block.position.offset(clickPos.x, clickPos.y, clickPos.z), true); + + // Get block runtime ID + const blockRuntimeId = (block as any).stateId || 0; + + const entityId = bot.entity.id; + const hotbarSlot = bot.quickBarSlot ?? 0; + const heldItem = bot.heldItem; + + // Helper to format item for packets (matching real client format) + function formatItemForPacket(item: typeof heldItem) { + if (!item) return { network_id: 0 }; + const notch = Item.toNotch(item, 0); + // Ensure extra fields have correct format + if (notch.extra) { + notch.extra.can_place_on = notch.extra.can_place_on || []; + notch.extra.can_destroy = notch.extra.can_destroy || []; + } else { + notch.extra = { has_nbt: 0, can_place_on: [], can_destroy: [] }; + } + // Real client sends has_stack_id: 0 for inventory_transaction items + notch.has_stack_id = 0; + delete notch.stack_id; + return notch; + } + + // 0. Send mob_equipment FIRST to sync held item state with server + const heldItemForEquip = formatItemForPacket(heldItem); + bot._client.write('mob_equipment', { + runtime_entity_id: entityId, + item: heldItemForEquip, + slot: hotbarSlot, + selected_slot: hotbarSlot, + window_id: 'inventory', + }); + + // 1. Send player_action: start_item_use_on + bot._client.write('player_action', { + runtime_entity_id: entityId, + action: 'start_item_use_on', + position: { + x: block.position.x, + y: block.position.y, + z: block.position.z, + }, + result_position: { + x: block.position.x, + y: block.position.y, + z: block.position.z, + }, + face: face, + }); + + // 2. Send animate: swing_arm with 'build' source + bot._client.write('animate', { + action_id: 'swing_arm', + runtime_entity_id: entityId, + data: 0, + has_swing_source: true, + swing_source: 'build', + }); + + // 3. Send inventory_transaction with item_use + const heldItemNotch = formatItemForPacket(heldItem); + const newCount = heldItem ? heldItem.count - 1 : 0; + const newItemNotch = newCount > 0 ? formatItemForPacket({ ...heldItem!, count: newCount } as typeof heldItem) : { network_id: 0 }; + + // Build actions array if we have a held item that will be consumed + // Use heldItem.slot for accuracy (hotbarSlot might differ) + const itemSlot = heldItem?.slot ?? hotbarSlot; + const actions = heldItem + ? [ + { + source_type: 'container', + inventory_id: 'inventory', + slot: itemSlot, + old_item: heldItemNotch, + new_item: newItemNotch, + }, + ] + : []; + + bot._client.write('inventory_transaction', { + transaction: { + legacy: { + legacy_request_id: 0, + }, + transaction_type: 'item_use', + actions: actions, + transaction_data: { + action_type: 'click_block', + trigger_type: 'player_input', + block_position: { + x: block.position.x, + y: block.position.y, + z: block.position.z, + }, + face: face, + hotbar_slot: hotbarSlot, + held_item: heldItemNotch, + player_pos: { + x: bot.entity.position.x, + y: bot.entity.position.y + 1.62, + z: bot.entity.position.z, + }, + click_pos: { + x: clickPos.x, + y: clickPos.y, + z: clickPos.z, + }, + block_runtime_id: blockRuntimeId, + client_prediction: 'success', + }, + }, + }); + + // 4. Send mob_equipment to update server with new held item count + const updatedItemNotch = newCount > 0 ? formatItemForPacket({ ...heldItem!, count: newCount } as typeof heldItem) : { network_id: 0 }; + + bot._client.write('mob_equipment', { + runtime_entity_id: entityId, + item: updatedItemNotch, + slot: itemSlot, + selected_slot: hotbarSlot, + window_id: 'inventory', + }); + + // 5. Send stop_item_use_on to signal end of placement + bot._client.write('player_action', { + runtime_entity_id: entityId, + action: 'stop_item_use_on', + position: { + x: block.position.x, + y: block.position.y, + z: block.position.z, + }, + result_position: { x: 0, y: 0, z: 0 }, + face: 0, + }); + + // 6. Wait for server to confirm the placement via inventory_content + // This ensures the server has processed our transaction before we return + await waitForInventorySync(200); + } + + async function placeBlock(referenceBlock: Block, faceVector: Vec3): Promise { + // placeBlock is essentially activateBlock with a direction + // Used for placing blocks/seeds on a reference block face + await activateBlock(referenceBlock, faceVector); + } + + // Entity items that can be placed + const ENTITY_ITEMS = ['boat', 'minecart', 'armor_stand', 'end_crystal', 'spawn_egg', 'item_frame', 'glow_item_frame']; + + function isEntityItem(itemName: string): boolean { + return ENTITY_ITEMS.some((e) => itemName.includes(e)); + } + + function getEntityNameFromItem(itemName: string): string { + // Map item names to entity names + if (itemName.includes('boat')) return 'boat'; + if (itemName.includes('minecart')) return 'minecart'; + if (itemName === 'armor_stand') return 'armor_stand'; + if (itemName === 'end_crystal') return 'ender_crystal'; + if (itemName === 'item_frame') return 'frame'; + if (itemName === 'glow_item_frame') return 'glow_frame'; + if (itemName.includes('spawn_egg')) { + // For spawn eggs, we'd need to check the entity type from item data + // For now, return a generic pattern + return 'spawned_entity'; + } + return itemName; + } + + async function placeEntity(referenceBlock: Block, faceVector: Vec3): Promise { + if (!bot.heldItem) { + throw new Error('must be holding an item to place an entity'); + } + + const itemName = bot.heldItem.name; + if (!isEntityItem(itemName)) { + throw new Error(`Item '${itemName}' is not a placeable entity item. Supported: ${ENTITY_ITEMS.join(', ')}`); + } + + const expectedEntityName = getEntityNameFromItem(itemName); + const placePos = referenceBlock.position.plus(faceVector); + + // Set up entity spawn listener before placing + const entityPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + bot.off('entitySpawn', listener); + reject(new Error('Failed to place entity: timeout waiting for spawn')); + }, 5000); + + function listener(entity: Entity) { + // Check if this is the entity we placed (by name and proximity) + // Use larger distance for boats (they can float/drift) + const dist = entity.position.distanceTo(placePos); + const maxDist = expectedEntityName === 'boat' ? 10 : 5; + const nameMatch = + entity.name?.includes(expectedEntityName) || + expectedEntityName === 'spawned_entity' || + (expectedEntityName === 'boat' && entity.name?.includes('boat')) || + (expectedEntityName === 'minecart' && entity.name?.includes('minecart')) || + (expectedEntityName === 'armor_stand' && entity.name?.includes('armor_stand')); + + if (nameMatch && dist < maxDist) { + clearTimeout(timeout); + bot.off('entitySpawn', listener); + resolve(entity); + } + } + + bot.on('entitySpawn', listener); + }); + + // Place the entity using the same mechanism as block placement + await activateBlock(referenceBlock, faceVector); + + // Wait for entity spawn + const entity = await entityPromise; + + // Emit the entityPlaced event + bot.emit('entityPlaced', entity); + + return entity; + } + + async function activateEntity(entity: Entity): Promise { + await bot.lookAt(entity.position.offset(0, 1, 0), false); + bot._client.write('interact', { + action_id: 'interact', + target_entity_id: entity.id, + position: { x: 0, y: 0, z: 0 }, + has_position: false, + }); + } + + async function activateEntityAt(entity: Entity, position: Vec3): Promise { + await bot.lookAt(position, false); + bot._client.write('interact', { + action_id: 'interact', + target_entity_id: entity.id, + position: { + x: position.x - entity.position.x, + y: position.y - entity.position.y, + z: position.z - entity.position.z, + }, + has_position: true, + }); + } + + // Consumable items that can always be used + const ALWAYS_CONSUMABLES = ['potion', 'milk_bucket', 'honey_bottle', 'suspicious_stew']; + const CONSUME_TIMEOUT = 5000; + + async function consume(): Promise { + if (!bot.heldItem) { + throw new Error('No item in hand'); + } + + // Check if food is full (unless always consumable or creative) + if (bot.game?.gameMode !== 'creative' && !ALWAYS_CONSUMABLES.includes(bot.heldItem.name) && bot.food === 20) { + throw new Error('Food is full'); + } + + bot.usingHeldItem = true; + + // Start using the item + activateItem(false); + + // Wait for eating to complete via inventory update + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + bot.usingHeldItem = false; + reject(new Error('Consume timeout')); + }, CONSUME_TIMEOUT); + + const onSlotUpdate = () => { + clearTimeout(timeout); + bot.usingHeldItem = false; + bot.inventory.removeListener('updateSlot', onSlotUpdate); + resolve(); + }; + + bot.inventory.once('updateSlot', onSlotUpdate); + }); + } + + function activateItem(offhand: boolean = false): void { + bot.usingHeldItem = true; + + const position = bot.entity.position; + const blockPos = position.floored(); + + // For Bedrock, we use inventory_transaction with item_use action type 'use' + bot._client.write('inventory_transaction', { + transaction: { + legacy: { + legacy_request_id: 0, + }, + transaction_type: 'item_use', + actions: [], + transaction_data: { + action_type: 'use', + trigger_type: 'player_input', + block_position: { + x: blockPos.x, + y: blockPos.y, + z: blockPos.z, + }, + face: -1, + hotbar_slot: bot.quickBarSlot ?? 0, + held_item: bot.heldItem ? Item.toNotch(bot.heldItem, 0) : { network_id: 0 }, + player_pos: { + x: position.x, + y: position.y + 1.62, + z: position.z, + }, + click_pos: { x: 0, y: 0, z: 0 }, + block_runtime_id: 0, + client_prediction: 'success', + }, + }, + }); + } + + function deactivateItem(): void { + bot.usingHeldItem = false; + + // Send player_action with abort_item_use + bot._client.write('player_action', { + runtime_entity_id: bot.entity.id, + action: 'abort_item_use', + position: { + x: bot.entity.position.x, + y: bot.entity.position.y, + z: bot.entity.position.z, + }, + result_position: { + x: 0, + y: 0, + z: 0, + }, + face: 0, + }); + } + + // Track cursor item for clickWindow operations + let cursorItem: Item | null = null; + + // Expose cursor item as selectedItem on windows + Object.defineProperty(bot.inventory, 'selectedItem', { + get: () => cursorItem, + set: (value) => { + cursorItem = value; + }, + }); + + async function clickWindow(slotIndex: number, mouseButton: number, mode: number): Promise { + const window = bot.currentWindow || bot.inventory; + assert.ok(mode >= 0 && mode <= 4, `Mode ${mode} is not supported (valid: 0-4)`); + + // Handle different click modes + if (mode === 1) return clickWindowMode1(slotIndex, window); + if (mode === 2) return clickWindowMode2(slotIndex, mouseButton, window); + if (mode === 3) return clickWindowMode3(slotIndex, window); + if (mode === 4) return clickWindowMode4(slotIndex, mouseButton, window); + + // Mode 0: Normal click (left/right) + const requestId = getNextItemStackRequestId(); + + // Drop from cursor (slot -999) + if (slotIndex === -999) { + if (!cursorItem) return; + const dropCount = mouseButton === 1 ? 1 : cursorItem.count; + + sendRequest( + bot, + requestId, + actions() + .drop(dropCount, cursor(getStackId(cursorItem))) + .build() + ); + + if (await waitForResponseShared(bot, requestId)) { + if (dropCount >= cursorItem.count) cursorItem = null; + else cursorItem.count -= dropCount; + } + return; + } + + const sourceItem = window.slots[slotIndex]; + const src = getContainerFromSlot(slotIndex, window); + + // Case 1: Cursor empty - pick up from slot + if (!cursorItem) { + if (!sourceItem) return; + const takeCount = mouseButton === 1 ? Math.ceil(sourceItem.count / 2) : sourceItem.count; + + sendRequest( + bot, + requestId, + actions() + .takeToCursor(takeCount, makeSlot(src.containerId, src.slot, getStackId(sourceItem))) + .build() + ); + + if (await waitForResponseShared(bot, requestId)) { + cursorItem = Object.assign(Object.create(Object.getPrototypeOf(sourceItem)), sourceItem); + cursorItem.count = takeCount; + (cursorItem as any).stackId = getStackId(sourceItem); + + if (takeCount >= sourceItem.count) window.updateSlot(slotIndex, null); + else { + sourceItem.count -= takeCount; + window.updateSlot(slotIndex, sourceItem); + } + } + return; + } + + // Case 2: Slot empty - place from cursor + if (!sourceItem) { + const placeCount = mouseButton === 1 ? 1 : cursorItem.count; + + sendRequest( + bot, + requestId, + actions() + .placeFromCursor(placeCount, getStackId(cursorItem), makeSlot(src.containerId, src.slot, 0)) + .build() + ); + + if (await waitForResponseShared(bot, requestId)) { + const newItem = Object.assign(Object.create(Object.getPrototypeOf(cursorItem)), cursorItem); + newItem.count = placeCount; + newItem.slot = slotIndex; + window.updateSlot(slotIndex, newItem); + + if (placeCount >= cursorItem.count) cursorItem = null; + else cursorItem.count -= placeCount; + } + return; + } + + // Case 3: Both have items - try to stack or swap + if (sourceItem.type === cursorItem.type && sourceItem.metadata === cursorItem.metadata && sourceItem.count < sourceItem.stackSize) { + const spaceAvailable = sourceItem.stackSize - sourceItem.count; + const placeCount = mouseButton === 1 ? 1 : Math.min(cursorItem.count, spaceAvailable); + + if (placeCount > 0) { + sendRequest( + bot, + requestId, + actions() + .placeFromCursor(placeCount, getStackId(cursorItem), makeSlot(src.containerId, src.slot, getStackId(sourceItem))) + .build() + ); + + if (await waitForResponseShared(bot, requestId)) { + sourceItem.count += placeCount; + window.updateSlot(slotIndex, sourceItem); + + if (placeCount >= cursorItem.count) cursorItem = null; + else cursorItem.count -= placeCount; + } + return; + } + } + + // Swap cursor with slot + sendRequest( + bot, + requestId, + actions() + .swap(makeSlot(src.containerId, src.slot, getStackId(sourceItem)), cursor(getStackId(cursorItem))) + .build() + ); + + if (await waitForResponseShared(bot, requestId)) { + const oldCursor = cursorItem; + cursorItem = sourceItem; + window.updateSlot(slotIndex, oldCursor); + } + } + + async function putSelectedItemRange(start: number, end: number, window: Window, slot: any): Promise { + // Put the cursor item into the slot range in window + // Try to stack with existing items first, then use empty slots + while (cursorItem) { + // Try to find an existing stack to combine with + const existingItem = window.findItemRange( + start, + end, + cursorItem.type, + cursorItem.metadata, + true, // notFull - only find stacks that aren't full + cursorItem.nbt + ); + + if (existingItem && existingItem.stackSize !== existingItem.count) { + // Found an existing stack to combine with + await clickWindow(existingItem.slot, 0, 0); + } else { + // No existing stack, find empty slot + const emptySlot = window.firstEmptySlotRange(start, end); + if (emptySlot === null) { + // No room left + if (slot === null) { + // Drop the item + await clickWindow(-999, 0, 0); + } else { + // Place at fallback slot, then drop any remainder + await clickWindow(slot, 0, 0); + if (cursorItem) { + await clickWindow(-999, 0, 0); + } + } + } else { + await clickWindow(emptySlot, 0, 0); + } + } + } + } + + async function putAway(slot: number): Promise { + const window = bot.currentWindow || bot.inventory; + const item = window.slots[slot]; + + if (!item) { + return; // Nothing to put away + } + + // Click on slot to pick up item to cursor + await clickWindow(slot, 0, 0); + + // Put cursor item into inventory + const start = window.inventoryStart ?? 9; + const end = window.inventoryEnd ?? 44; + await putSelectedItemRange(start, end, window, null); + } + + async function closeWindow(window: Window): Promise { + if (!window || window === bot.inventory) return; // Can't close player inventory + + bot._client.write('container_close', { + window_id: window.id, + window_type: 'none', + server: false, + }); + + // Wait for server confirmation + await onceWithCleanup(bot._client, 'container_close', 5000); + } + + async function transfer(options: TransferOptions): Promise { + const window = options.window || bot.currentWindow || bot.inventory; + const itemType = options.itemType; + const metadata = options.metadata; + const nbt = options.nbt; + let count = options.count === undefined || options.count === null ? 1 : options.count; + let firstSourceSlot: number | null = null; + + // ranges + const sourceStart = options.sourceStart; + const destStart = options.destStart; + assert.notStrictEqual(sourceStart, null, 'sourceStart is required'); + assert.notStrictEqual(destStart, null, 'destStart is required'); + const sourceEnd = options.sourceEnd === null ? sourceStart + 1 : options.sourceEnd; + const destEnd = options.destEnd === null ? destStart + 1 : options.destEnd; + + await transferOne(); + + async function transferOne(): Promise { + if (count === 0) { + await putSelectedItemRange(sourceStart, sourceEnd, window, firstSourceSlot); + return; + } + + // Check if we need to pick up a new item + if (!cursorItem || cursorItem.type !== itemType || (metadata != null && cursorItem.metadata !== metadata) || (nbt != null && cursorItem.nbt !== nbt)) { + // Find item in source range + const sourceItem = window.findItemRange(sourceStart, sourceEnd, itemType, metadata, false, nbt); + const mcDataEntry = bot.registry.itemsArray.find((x: any) => x.id === itemType); + assert(mcDataEntry, 'Invalid itemType'); + + if (!sourceItem) { + throw new Error(`Can't find ${mcDataEntry.name} in slots [${sourceStart} - ${sourceEnd}], (item id: ${itemType})`); + } + + if (firstSourceSlot === null) { + firstSourceSlot = sourceItem.slot; + } + + // Pick up item to cursor + await clickWindow(sourceItem.slot, 0, 0); + } + + await clickDest(); + } + + async function clickDest(): Promise { + assert.notStrictEqual(cursorItem?.type, null); + + let destItem: Item | null = null; + let destSlot: number | null; + + // Special case for tossing + if (destStart === -999) { + destSlot = -999; + } else { + // Find a non-full item to stack with + destItem = window.findItemRange(destStart, destEnd, cursorItem!.type, cursorItem!.metadata, true, nbt); + + // If no stackable item, find empty slot + destSlot = destItem ? destItem.slot : window.firstEmptySlotRange(destStart, destEnd); + + if (destSlot === null) { + throw new Error('destination full'); + } + } + + // Calculate how many items we can move + const destSlotCount = destItem?.count ?? 0; + const movedItems = Math.min(cursorItem!.stackSize - destSlotCount, cursorItem!.count); + + // If moving more items than count needs, use right-click to place one at a time + if (movedItems <= count) { + await clickWindow(destSlot, 0, 0); + count -= movedItems; + await transferOne(); + } else { + // Right-click to place one at a time + for (let i = 0; i < count && cursorItem; i++) { + await clickWindow(destSlot, 1, 0); + } + count = 0; + await putSelectedItemRange(sourceStart, sourceEnd, window, firstSourceSlot); + } + } + } + + async function openBlock(block: Block, direction?: Vec3, cursorPos?: Vec3): Promise { + // Calculate face direction based on bot position relative to block + let face: number; + if (direction) { + face = getFaceFromDirection(direction); + } else { + // Auto-calculate face based on bot position + const dx = bot.entity.position.x - block.position.x - 0.5; + const dy = bot.entity.position.y - block.position.y - 0.5; + const dz = bot.entity.position.z - block.position.z - 0.5; + const ax = Math.abs(dx), + ay = Math.abs(dy), + az = Math.abs(dz); + + if (ax >= ay && ax >= az) { + face = dx > 0 ? 5 : 4; // east or west + } else if (ay >= ax && ay >= az) { + face = dy > 0 ? 1 : 0; // top or bottom + } else { + face = dz > 0 ? 3 : 2; // south or north + } + } + + // Click position on the face (center of face, with face-appropriate coordinate at edge) + // Face: 0=bottom(-Y), 1=top(+Y), 2=north(-Z), 3=south(+Z), 4=west(-X), 5=east(+X) + let clickPos = cursorPos; + if (!clickPos) { + clickPos = { x: 0.5, y: 0.5, z: 0.5 }; + if (face === 0) clickPos.y = 0; // bottom face + else if (face === 1) clickPos.y = 1; // top face + else if (face === 2) clickPos.z = 0; // north face + else if (face === 3) clickPos.z = 1; // south face + else if (face === 4) clickPos.x = 0; // west face + else if (face === 5) clickPos.x = 1; // east face + } + await bot.lookAt(block.position.offset(clickPos.x, clickPos.y, clickPos.z), true); + + // Get block runtime ID from the block (stateId is signed) + const blockRuntimeId = (block as any).stateId || 0; + + // Result position is same as block position for container interactions (from packet capture) + const resultPosition = { + x: block.position.x, + y: block.position.y, + z: block.position.z, + }; + + // Get entity ID as string (handle BigInt) + const entityId = bot.entity.id; + + // 1. Send player_action: start_item_use_on (like real client) + bot._client.write('player_action', { + runtime_entity_id: entityId, + action: 'start_item_use_on', + position: { + x: block.position.x, + y: block.position.y, + z: block.position.z, + }, + result_position: resultPosition, + face: face, + }); + + // 2. Send animate: swing_arm (like real client) + bot._client.write('animate', { + action_id: 'swing_arm', + runtime_entity_id: entityId, + data: 0, + has_swing_source: true, + swing_source: 'interact', + }); + + // 3. Send inventory_transaction to interact with the block + bot._client.write('inventory_transaction', { + transaction: { + legacy: { + legacy_request_id: 0, + }, + transaction_type: 'item_use', + actions: [], + transaction_data: { + action_type: 'click_block', + trigger_type: 'player_input', + block_position: { + x: block.position.x, + y: block.position.y, + z: block.position.z, + }, + face: face, + hotbar_slot: bot.quickBarSlot ?? 0, + held_item: bot.heldItem ? Item.toNotch(bot.heldItem, 0) : { network_id: 0 }, + player_pos: { + x: bot.entity.position.x, + y: bot.entity.position.y + 1.62, // Eye height + z: bot.entity.position.z, + }, + click_pos: { + x: clickPos.x, + y: clickPos.y, + z: clickPos.z, + }, + block_runtime_id: blockRuntimeId, + client_prediction: 'success', + }, + }, + }); + + // Wait for container_open response + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + bot.removeListener('windowOpen', onWindowOpen); + reject(new Error('Timeout waiting for container to open')); + }, 10000); + + const onWindowOpen = (window: Window) => { + clearTimeout(timeout); + resolve(window); + }; + + bot.once('windowOpen', onWindowOpen); + }); + } + + function getFaceFromDirection(direction: Vec3): number { + // Convert direction vector to face ID + // 0 = bottom (-Y), 1 = top (+Y), 2 = north (-Z), 3 = south (+Z), 4 = west (-X), 5 = east (+X) + if (direction.y < 0) return 0; + if (direction.y > 0) return 1; + if (direction.z < 0) return 2; + if (direction.z > 0) return 3; + if (direction.x < 0) return 4; + if (direction.x > 0) return 5; + return 1; // Default to top + } + + async function openEntity(entity: Entity, Class: new () => EventEmitter): Promise { + // Send interact packet to open entity's inventory + bot._client.write('interact', { + action_id: 'open_inventory', + target_entity_id: entity.id, + has_position: false, + }); + + // Wait for container_open response + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + bot.removeListener('windowOpen', onWindowOpen); + reject(new Error('Timeout waiting for entity container to open')); + }, 10000); + + const onWindowOpen = (window: Window) => { + clearTimeout(timeout); + resolve(window); + }; + + bot.once('windowOpen', onWindowOpen); + }); + } + + async function openInventory() { + // Step 1: Open the inventory by interacting with the player entity + bot._client.write('interact', { + action_id: 'open_inventory', + target_entity_id: bot.entity.id, + has_position: false, + }); + + return await onceWithCleanup(bot._client, 'container_open'); + } + + async function moveSlotItem(sourceSlot: number, destSlot: number): Promise { + const sourceItem = bot.inventory.slots[sourceSlot]; + if (!sourceItem) throw new Error(`No item at source slot ${sourceSlot}`); + + const destItem = bot.inventory.slots[destSlot]; + const src = getContainerForCursorOp(sourceSlot); + const dst = getContainerForCursorOp(destSlot); + const sourceCount = sourceItem.count; + const destCount = destItem?.count ?? 0; + + if (!bot.currentWindow || bot.currentWindow.id !== 'inventory') { + await openInventory(); + } + + // Offhand requires two separate requests + if (dst.containerId === 'offhand' && !destItem) { + const takeId = getNextItemStackRequestId(); + sendRequest( + bot, + takeId, + actions() + .takeToCursor(sourceItem.count, makeSlot(src.containerId, src.slot, getStackId(sourceItem))) + .build() + ); + if (!(await waitForResponseShared(bot, takeId))) throw new Error('Failed to take item to cursor for offhand'); + + const placeId = getNextItemStackRequestId(); + sendRequest( + bot, + placeId, + actions() + .placeFromCursor(sourceItem.count, getStackId(sourceItem), makeSlot(dst.containerId, dst.slot, 0)) + .build() + ); + if (!(await waitForResponseShared(bot, placeId))) throw new Error('Failed to place item to offhand'); + } else { + const requestId = getNextItemStackRequestId(); + const builder = actions(); + + if (destItem) { + builder.swap(makeSlot(src.containerId, src.slot, getStackId(sourceItem)), makeSlot(dst.containerId, dst.slot, getStackId(destItem))); + } else { + builder + .takeToCursor(sourceItem.count, makeSlot(src.containerId, src.slot, getStackId(sourceItem))) + .placeFromCursor(sourceItem.count, getStackId(sourceItem), makeSlot(dst.containerId, dst.slot, 0)); + } + + sendRequest(bot, requestId, builder.build()); + if (!(await waitForResponseShared(bot, requestId))) throw new Error('Failed to move item'); + } + + // Update local state + if (destItem) { + sourceItem.count = sourceCount; + destItem.count = destCount; + bot.inventory.updateSlot(sourceSlot, destItem); + bot.inventory.updateSlot(destSlot, sourceItem); + } else { + sourceItem.count = sourceCount; + bot.inventory.updateSlot(destSlot, sourceItem); + bot.inventory.updateSlot(sourceSlot, null); + } + + if (bot.currentWindow) { + bot._client.write('container_close', { + window_id: bot.currentWindow.id, + window_type: 'none', + server: false, + }); + await onceWithCleanup(bot._client, 'container_close', 5000); + } + + updateHeldItem(); + } + + function updateHeldItem(): void { + bot.emit('heldItemChanged', bot.heldItem); + } + + ////helpers + + function getSlotIndex(window_id: protocolTypes.WindowID, slot: number) { + switch (window_id) { + case 'inventory': + return slot; + case 'armor': + return 36 + slot; // armor slots 36-39 (head, torso, legs, feet) + case 'offhand': + return 45 + slot; // offhand at slot 45 (Java compatibility) + case 'hotbar': + return slot; + default: + return slot; + break; + } + } + function getWindow(window_id: protocolTypes.WindowID): Window | null { + if (window_id === 'inventory' || window_id === 'armor' || window_id === 'offhand' || window_id === 'hotbar' || window_id === 'fixed_inventory') { + return bot.inventory; + } else if (window_id === 'ui') { + return null; + } else { + // For container windows (chest, furnace, etc.), use currentWindow + // Returns null if no container is currently open + return bot.currentWindow; + } + } + + function getContainerFromSlot( + slotIndex: number, + window?: Window + ): { + containerId: string; + slot: number; + } { + // If we have a container window open, check if slot is in container section + if (window && window !== bot.inventory && window.inventoryStart !== undefined) { + if (slotIndex < window.inventoryStart) { + // Container slot (e.g., chest slots 0-26) + return { containerId: 'container', slot: slotIndex }; + } + // Adjust slot index for player inventory section within container window + // Window slots 27-62 map to player inventory + const playerSlot = slotIndex - window.inventoryStart; + if (playerSlot >= 0 && playerSlot <= 8) { + return { containerId: 'hotbar', slot: playerSlot }; + } else if (playerSlot >= 9 && playerSlot <= 35) { + // Main inventory + return { containerId: 'inventory', slot: playerSlot }; + } + } + + // Player inventory layout (Java compatible): + // 0-8: hotbar (hotbar slots 0-8) + // 9-35: main inventory (inventory slots 9-35) + // 36-39: armor (armor slots 0-3: head, torso, legs, feet) + // 45: offhand (offhand slot 0) + + if (slotIndex >= 0 && slotIndex <= 8) { + // Hotbar + return { containerId: 'hotbar', slot: slotIndex }; + } else if (slotIndex >= 9 && slotIndex <= 35) { + // Main inventory + return { containerId: 'inventory', slot: slotIndex }; + } else if (slotIndex >= 36 && slotIndex <= 39) { + // Armor slots (head, torso, legs, feet) + return { containerId: 'armor', slot: slotIndex - 36 }; + } else if (slotIndex === 45) { + // Offhand (uses slot 1 in item_stack_request, not 0) + return { containerId: 'offhand', slot: 1 }; + } else { + throw new Error(`Invalid slot index: ${slotIndex}`); + } + } + + /** + * Mode 1: Shift-click (quick transfer) + * Transfers item from clicked slot to the opposite inventory section + */ + async function clickWindowMode1(slotIndex: number, window: Window): Promise { + const sourceItem = window.slots[slotIndex]; + if (!sourceItem) return; + + // Determine destination range based on source and container state + let destStart: number, destEnd: number; + if (bot.currentWindow && bot.currentWindow !== bot.inventory) { + const containerSlots = window.inventoryStart ?? 27; + if (slotIndex < containerSlots) { + destStart = containerSlots; + destEnd = window.inventoryEnd ?? 62; + } else { + destStart = 0; + destEnd = containerSlots - 1; + } + } else if (slotIndex <= 8) { + destStart = 9; + destEnd = 35; + } else if (slotIndex <= 35) { + destStart = 0; + destEnd = 8; + } else { + destStart = 9; + destEnd = 35; + } + + // Find stackable or empty destination slot + let destSlotIndex: number | null = null; + let destItem: Item | null = null; + + if (sourceItem.stackSize > 1) { + for (let i = destStart; i <= destEnd; i++) { + const item = window.slots[i]; + if (item && item.type === sourceItem.type && item.metadata === sourceItem.metadata && item.count < item.stackSize) { + destSlotIndex = i; + destItem = item; + break; + } + } + } + if (destSlotIndex === null) { + for (let i = destStart; i <= destEnd; i++) { + if (!window.slots[i]) { + destSlotIndex = i; + break; + } + } + } + if (destSlotIndex === null) return; + + const src = getContainerFromSlot(slotIndex, window); + const dst = getContainerFromSlot(destSlotIndex, window); + const spaceAvailable = destItem ? destItem.stackSize - destItem.count : sourceItem.stackSize; + const transferCount = Math.min(sourceItem.count, spaceAvailable); + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .takeToCursor(transferCount, makeSlot(src.containerId, src.slot, getStackId(sourceItem))) + .placeFromCursor(transferCount, getStackId(sourceItem), makeSlot(dst.containerId, dst.slot, getStackId(destItem))) + .build() + ); + + if (await waitForResponseShared(bot, requestId)) { + if (transferCount >= sourceItem.count) { + window.updateSlot(slotIndex, null); + } else { + sourceItem.count -= transferCount; + window.updateSlot(slotIndex, sourceItem); + } + + if (destItem) { + destItem.count += transferCount; + window.updateSlot(destSlotIndex, destItem); + } else { + const newItem = Object.assign(Object.create(Object.getPrototypeOf(sourceItem)), sourceItem); + newItem.count = transferCount; + newItem.slot = destSlotIndex; + window.updateSlot(destSlotIndex, newItem); + } + } + } + + /** + * Mode 2: Number key swap (hotbar swap) + * Swaps item in clicked slot with hotbar slot indicated by mouseButton (0-8) + */ + async function clickWindowMode2(slotIndex: number, mouseButton: number, window: Window): Promise { + assert.ok(mouseButton >= 0 && mouseButton <= 8, 'mouseButton must be 0-8 for mode 2'); + + const hotbarSlot = mouseButton; + if (slotIndex === hotbarSlot) return; + + const sourceItem = window.slots[slotIndex]; + const hotbarItem = window.slots[hotbarSlot]; + if (!sourceItem && !hotbarItem) return; + + const src = getContainerFromSlot(slotIndex, window); + const dst = getContainerFromSlot(hotbarSlot, window); + const requestId = getNextItemStackRequestId(); + + const builder = actions(); + if (sourceItem && hotbarItem) { + // Both have items - swap + builder.swap(makeSlot(src.containerId, src.slot, getStackId(sourceItem)), makeSlot(dst.containerId, dst.slot, getStackId(hotbarItem))); + } else if (sourceItem) { + // Move source to hotbar via cursor + builder + .takeToCursor(sourceItem.count, makeSlot(src.containerId, src.slot, getStackId(sourceItem))) + .placeFromCursor(sourceItem.count, getStackId(sourceItem), makeSlot(dst.containerId, dst.slot, 0)); + } else { + // Move hotbar to source via cursor + builder + .takeToCursor(hotbarItem!.count, makeSlot(dst.containerId, dst.slot, getStackId(hotbarItem))) + .placeFromCursor(hotbarItem!.count, getStackId(hotbarItem), makeSlot(src.containerId, src.slot, 0)); + } + + sendRequest(bot, requestId, builder.build()); + + if (await waitForResponseShared(bot, requestId)) { + window.updateSlot(slotIndex, hotbarItem); + window.updateSlot(hotbarSlot, sourceItem); + } + } + + /** + * Mode 3: Creative clone (middle-click) + * Clones item from slot to cursor with full stack + */ + async function clickWindowMode3(slotIndex: number, window: Window): Promise { + if (bot.game?.gameMode !== 'creative') { + throw new Error('Mode 3 (creative clone) only works in creative mode'); + } + + const sourceItem = window.slots[slotIndex]; + if (!sourceItem) return; + + const requestId = getNextItemStackRequestId(); + const stackSize = sourceItem.stackSize || 64; + + sendRequest( + bot, + requestId, + actions() + .create(0) + .place(stackSize, makeSlot('created_output', 0, 0), cursor(0)) + .build() + ); + + if (await waitForResponseShared(bot, requestId)) { + const clonedItem = new Item(sourceItem.type, stackSize, sourceItem.metadata, sourceItem.nbt); + cursorItem = clonedItem; + } + } + + /** + * Mode 4: Q key drop + * mouseButton 0: Drop 1 item from slot + * mouseButton 1: Drop entire stack from slot (Ctrl+Q) + */ + async function clickWindowMode4(slotIndex: number, mouseButton: number, window: Window): Promise { + const sourceItem = window.slots[slotIndex]; + if (!sourceItem) return; + + const sourceContainer = getContainerFromSlot(slotIndex, window); + const dropCount = mouseButton === 1 ? sourceItem.count : 1; + const requestId = getNextItemStackRequestId(); + + sendRequest( + bot, + requestId, + actions() + .drop(dropCount, makeSlot(sourceContainer.containerId, sourceContainer.slot, getStackId(sourceItem))) + .build() + ); + + if (await waitForResponseShared(bot, requestId)) { + if (dropCount >= sourceItem.count) { + window.updateSlot(slotIndex, null); + } else { + sourceItem.count -= dropCount; + window.updateSlot(slotIndex, sourceItem); + } + } + } + + function onceWithCleanup(source: EventEmitter, name: string, timeoutMs: number = 10000): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { + source.removeListener(name, listener); + resolve(false); + }, timeoutMs); // 5 second timeout + + const listener = (...params) => { + clearTimeout(timeout); + resolve(params); + }; + + source.once(name, listener); + }); + } +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/inventory_minimal.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/inventory_minimal.mts new file mode 100644 index 0000000..ed3cebc --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/inventory_minimal.mts @@ -0,0 +1,81 @@ +import type { BedrockBot } from '../../index.js'; + +export default function inject(bot: BedrockBot) { + //0-8, null = uninitialized + // which quick bar slot is selected + bot.quickBarSlot = null; + // TODO: make it load into slots properly + bot.inventory = { + slots: [], + }; + bot.heldItem = { + // null? + network_id: 0, + }; + + bot.selectedSlot = null; + bot.usingHeldItem = false; + + bot._client.on('inventory_content', (packet) => { + if (!bot.inventory) + bot.inventory = { + slots: [], + }; + bot.inventory[packet.window_id] = packet.input; + }); + + bot._client.on('inventory_slot', (packet) => { + if (!bot.inventory) + bot.inventory = { + slots: [], + }; + bot.inventory[packet.window_id] = []; + bot.inventory[packet.window_id][packet.slot] = packet.item; + if (bot.inventory && bot.selectedSlot) { + bot.heldItem = bot.inventory.inventory[bot.selectedSlot]; + } + }); + + bot._client.on('player_hotbar', (packet) => { + if (packet.select_slot) { + bot.selectedSlot = packet.selected_slot; + if (bot.inventory.inventory) { + bot.heldItem = bot.inventory[packet.window_id][packet.selected_slot]; + } + } + }); + + function useItem(slotNumber: number) { + bot.usingHeldItem = true; + bot._client.write('inventory_transaction', { + transaction: { + legacy: { + legacy_request_id: 0, + }, + transaction_type: 'item_use', + actions: [], + transaction_data: { + action_type: 'click_air', + block_position: { x: 0, y: 0, z: 0 }, + face: bot.entity.yaw, // facing? + hotbar_slot: slotNumber + 1, + held_item: bot.inventory.inventory[slotNumber - 1], + player_pos: bot.entity.position, + click_pos: { x: 0, y: 0, z: 0 }, + block_runtime_id: 0, + }, + }, + }); + } + function swingArm(arm = 'right', showHand = true) { + //const hand = arm === 'right' ? 0 : 1 + const packet = { + action_id: 'swing_arm', + runtime_entity_id: (bot.entity as any).runtime_entity_id, + }; + bot._client.write('animate', packet); + } + + bot.swingArm = swingArm; + bot.useItem = useItem; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/kick.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/kick.mts new file mode 100644 index 0000000..c2ee809 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/kick.mts @@ -0,0 +1,12 @@ +import type { BedrockBot } from '../../index.js'; + +export default function inject(bot: BedrockBot) { + bot._client.on('disconnect', (packet) => { + const kicked = packet.reason.indexOf('kick') !== -1; + bot.emit('kicked', packet.message ?? packet.reason, kicked); + }); + bot.quit = (reason) => { + reason = reason ?? 'disconnect.quitting'; + bot.end(reason); + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/particle.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/particle.mts new file mode 100644 index 0000000..1ecaa2a --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/particle.mts @@ -0,0 +1,18 @@ +import type { BedrockBot } from '../../index.js'; +import { Vec3 } from 'vec3'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +// REQ BEDROCK PARTICLES IMPLEMENTATION +export default function inject(bot: BedrockBot) { + const Particle = require('../particle')(bot.registry); + + bot._client.on('level_event', (packet) => { + if (packet.event.startsWith('particle')) { + bot.emit('particle', new Particle(packet.event, packet.position, new Vec3(0, 0, 0))); + } + }); + bot._client.on('spawn_particle_effect', (packet) => { + bot.emit('particle', new Particle(packet.particle_name, packet.position, new Vec3(0, 0, 0))); + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/physics.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/physics.mts new file mode 100644 index 0000000..b5a246f --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/physics.mts @@ -0,0 +1,601 @@ +import type { BedrockBot } from '../../index.js'; +import { Vec3 } from 'vec3'; +import assert from 'assert'; +import math from '../math.js'; +import conv from '../conversions.js'; +import { performance } from 'perf_hooks'; +import { createDoneTask, createTask } from '../promise_utils.js'; + +import { InputDataService } from './input-data-service.mts'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); +const { Physics, PlayerState } = require('./attribute-patch.js'); + +const PI = Math.PI; +const PI_2 = Math.PI * 2; +const PHYSICS_INTERVAL_MS = 50; +const PHYSICS_TIMESTEP = PHYSICS_INTERVAL_MS / 1000; + +interface PhysicsOptions { + physicsEnabled?: boolean; +} + +export default function inject(bot: BedrockBot, { physicsEnabled }: PhysicsOptions = {}) { + const world = { + getBlock: (pos: Vec3) => { + return bot.blockAt(pos, false); + }, + }; + const physics = Physics(bot.registry, world); + physics.sprintingUUID = 'd208fc00-42aa-4aad-9276-d5446530de43'; + physics.sprintSpeed = Math.fround(physics.sprintSpeed); + physics.playerSpeed = Math.fround(physics.playerSpeed); + + const positionUpdateSentEveryTick = true; // depends on server settings, non-auth movement sends updates only when pos/rot changes + + bot.jumpQueued = false; + bot.jumpTicks = 0; // autojump cooldown + + const controlState = { + forward: false, + back: false, + left: false, + right: false, + jump: false, + sprint: false, + sneak: false, + }; + let lastSentJumping = false; + let lastSentSprinting = false; + let lastSentSneaking = false; + let lastSentYaw: number | null = null; + let lastSentPitch: number | null = null; + let lastSentHeadYaw: number | null = null; + + let doPhysicsTimer: ReturnType | null = null; + let lastPhysicsFrameTime: number | null = null; + let shouldUsePhysics = false; + bot.physicsEnabled = physicsEnabled ?? true; + + let tick = 0n; + + const inputDataService = new InputDataService(); + + const lastSent: protocolTypes.packet_player_auth_input = { + pitch: 0, + yaw: 0, // change + position: new Vec3(0, 0, 0), // change + move_vector: { x: 0, z: 0 }, // change + head_yaw: 0, // change + input_data: { + ascend: false, + descend: false, + north_jump: false, + jump_down: false, + sprint_down: false, + change_height: false, + jumping: false, + auto_jumping_in_water: false, + sneaking: false, + sneak_down: false, + up: false, + down: false, + left: false, + right: false, + up_left: false, + up_right: false, + want_up: false, + want_down: false, + want_down_slow: false, + want_up_slow: false, + sprinting: false, + ascend_block: false, + descend_block: false, + sneak_toggle_down: false, + persist_sneak: false, + start_sprinting: false, + stop_sprinting: false, + start_sneaking: false, + stop_sneaking: false, + start_swimming: false, + stop_swimming: false, + start_jumping: false, + start_gliding: false, + stop_gliding: false, + item_interact: false, + block_action: false, + item_stack_request: false, + handled_teleport: false, + emoting: false, + missed_swing: false, + start_crawling: false, + stop_crawling: false, + start_flying: false, + stop_flying: false, + received_server_data: false, + client_predicted_vehicle: false, + paddling_left: false, + paddling_right: false, + block_breaking_delay_enabled: true, + horizontal_collision: false, + vertical_collision: true, + down_left: false, + down_right: false, + start_using_item: false, + camera_relative_movement_enabled: false, + rot_controlled_by_move_direction: false, + start_spin_attack: false, + stop_spin_attack: false, + hotbar_only_touch: false, + jump_released_raw: false, + jump_pressed_raw: false, + jump_current_raw: false, + sneak_released_raw: false, + sneak_pressed_raw: false, + sneak_current_raw: false, + }, + input_mode: 'mouse', + play_mode: 'screen', + interaction_model: 'touch', + interact_rotation: { x: 0, z: 0 }, + // gaze_direction: undefined, + tick: tick, + delta: new Vec3(0, 0, 0), // velocity change + transaction: undefined, + block_action: undefined, + analogue_move_vector: { x: 0, z: 0 }, // for versions (1.19.80) > 1.19.30 + camera_orientation: { x: 0, y: 0, z: 0 }, + raw_move_vector: { x: 0, z: 0 }, + }; + + // This function should be executed each tick (every 0.05 seconds) + // How it works: https://gafferongames.com/post/fix_your_timestep/ + let timeAccumulator = 0; + let subchunkContainingPlayer: Vec3 | null = null; + + function getChunkCoordinates(pos: Vec3) { + let chunkX = Math.floor(pos.x / 16); + let chunkZ = Math.floor(pos.z / 16); + let subchunkY = Math.floor(pos.y / 16); + return new Vec3(chunkX, subchunkY, chunkZ); + } + + function updateCamera() { + if ((bot as any).cameraState) { + const maxPitch = 0.5 * Math.PI; + const minPitch = -0.5 * Math.PI; + const pitch = bot.entity.pitch + (bot as any).cameraState.pitch * 300; + bot.look(bot.entity.yaw + (bot as any).cameraState.yaw * 300, Math.max(minPitch, Math.min(maxPitch, pitch)), true); + } + } + + function doPhysics() { + const now = performance.now(); + const deltaSeconds = (now - lastPhysicsFrameTime!) / 1000; + lastPhysicsFrameTime = now; + + timeAccumulator += deltaSeconds; + + while (timeAccumulator >= PHYSICS_TIMESTEP) { + if (bot.physicsEnabled && shouldUsePhysics) { + updateCamera(); + physics.simulatePlayer(new PlayerState(bot, controlState), world).apply(bot); + let subchunkContainingPlayerNew = getChunkCoordinates(bot.entity.position); + if (subchunkContainingPlayerNew !== subchunkContainingPlayer) { + subchunkContainingPlayer = subchunkContainingPlayerNew; + bot.emit('subchunkContainingPlayerChanged', subchunkContainingPlayerNew); + } + bot.emit('physicsTick'); + bot.emit('physicTick'); // Deprecated, only exists to support old plugins. May be removed in the future + } + updatePosition(PHYSICS_TIMESTEP); + timeAccumulator -= PHYSICS_TIMESTEP; + } + } + + function cleanup() { + clearInterval(doPhysicsTimer!); + doPhysicsTimer = null; + } + + let player_auth_input_transaction: any = {}; + let tasks_queue: Array<() => void> = []; + + bot.sendPlayerAuthInputTransaction = async function (params = {}, wait = true) { + Object.assign(player_auth_input_transaction, params); + + // if (wait) return await once(bot, 'updatePlayerPosition') + if (wait) await new Promise((resolve) => tasks_queue.push(resolve)); // Fix this, temp workaround for too many listeners + + return null; + }; + + function updateCameraOrentation() { + const pitchRadians = lastSent.pitch * (Math.PI / 180); + const yawRadians = lastSent.yaw * (Math.PI / 180); + + lastSent.camera_orientation.x = -Math.cos(pitchRadians) * Math.sin(yawRadians); + lastSent.camera_orientation.y = -Math.sin(pitchRadians); + lastSent.camera_orientation.z = Math.cos(pitchRadians) * Math.cos(yawRadians); + } + + function updateInteractRotation() { + lastSent.interact_rotation.x = lastSent.pitch; + lastSent.interact_rotation.z = lastSent.head_yaw; + } + + function updateTransactions() { + if (player_auth_input_transaction?.transaction) { + lastSent.input_data.item_interact = true; + lastSent.transaction = player_auth_input_transaction.transaction; + delete player_auth_input_transaction.transaction; + } else { + lastSent.input_data.item_interact = false; + lastSent.transaction = undefined; + } + + if (player_auth_input_transaction?.block_action) { + lastSent.input_data.block_action = true; + lastSent.block_action = player_auth_input_transaction.block_action; + delete player_auth_input_transaction.block_action; + } else { + lastSent.input_data.block_action = false; + lastSent.block_action = undefined; + } + if (player_auth_input_transaction?.item_stack_request) { + lastSent.input_data.item_stack_request = true; + lastSent.item_stack_request = player_auth_input_transaction.item_stack_request.requests[0]; + delete player_auth_input_transaction.item_stack_request; + } else { + lastSent.input_data.item_stack_request = false; + delete lastSent.item_stack_request; + } + for (const resolve of tasks_queue) resolve(); + tasks_queue = []; + } + + function updateMoveVector() { + let moveVector = { x: 0, z: 0 }; + let max_value = controlState.sneak ? 0.3 : 1; + + if (controlState.forward) { + moveVector.z += max_value; + } + if (controlState.back) { + moveVector.z -= max_value; + } + if (controlState.left) { + moveVector.x -= max_value; + } + if (controlState.right) { + moveVector.x += max_value; + } + + let magnitude = (moveVector.x ** 2 + moveVector.z ** 2) ** 0.5; + + if (magnitude > 1) { + moveVector.x /= magnitude; + moveVector.z /= magnitude; + } + + lastSent.move_vector = moveVector; + lastSent.raw_move_vector = moveVector; + } + + function updateAuthoritativeMovementFlags() { + const inputDataDiff = inputDataService.update(controlState); + + lastSent.input_data.up = controlState.forward; + lastSent.input_data.down = controlState.back; + lastSent.input_data.right = controlState.right; + lastSent.input_data.left = controlState.left; + + lastSent.input_data.up_right = controlState.forward && controlState.right; + lastSent.input_data.up_left = controlState.forward && controlState.left; + + if (lastSent.input_data.start_jumping === controlState.jump) { + lastSent.input_data.start_jumping = false; + lastSent.input_data.jump_pressed_raw = false; + } + if (controlState.jump !== lastSentJumping) { + lastSentJumping = controlState.jump; + lastSent.input_data.jumping = controlState.jump; + lastSent.input_data.want_up = controlState.jump; + lastSent.input_data.jump_down = controlState.jump; + lastSent.input_data.start_jumping = controlState.jump; + lastSent.input_data.jump_current_raw = controlState.jump; + lastSent.input_data.jump_pressed_raw = controlState.jump; + lastSent.input_data.jump_released_raw = !controlState.jump; + } + if (controlState.sprint !== lastSentSprinting) { + lastSentSprinting = controlState.sprint; + lastSent.input_data.sprint_down = controlState.sprint; + lastSent.input_data.sprinting = controlState.sprint; + lastSent.input_data.stop_sprinting = !controlState.sprint; + } + if (controlState.sneak !== lastSentSneaking) { + lastSentSneaking = controlState.sneak; + lastSent.input_data.sneak_down = controlState.sneak; + lastSent.input_data.sneaking = controlState.sneak; + lastSent.input_data.stop_sneaking = !controlState.sneak; + lastSent.input_data.sneak_current_raw = controlState.sneak; + lastSent.input_data.sneak_pressed_raw = controlState.sneak; + lastSent.input_data.sneak_released_raw = !controlState.sneak; + } + + lastSent.input_data.vertical_collision = bot.entity.isCollidedVertically; + lastSent.input_data.horizontal_collision = bot.entity.isCollidedHorizontally; + for (const [property, value] of Object.entries(inputDataDiff.diff)) { + lastSent.input_data[property] = value; + } + } + + function sendMovementUpdate(position: Vec3, yaw: number, pitch: number) { + lastSent.tick = lastSent.tick + BigInt(1); + + // sends data, no logic + const oldPos = new Vec3(lastSent.x, lastSent.y, lastSent.z); + + lastSent.delta = bot.entity.velocity; + + lastSent.position = new Vec3(position.x, position.y + bot.entity.height, position.z); + + lastSent.yaw = yaw; + lastSent.pitch = pitch; + lastSent.head_yaw = yaw; + lastSent.item_stack_request; + + updateCameraOrentation(); + updateInteractRotation(); + updateTransactions(); + updateMoveVector(); + updateAuthoritativeMovementFlags(); + + bot._client.write('player_auth_input', lastSent); + + bot.emit('move', oldPos); + } + + function deltaYaw(yaw1: number, yaw2: number | null) { + let dYaw = (yaw1 - (yaw2 ?? 0)) % PI_2; + if (dYaw < -PI) dYaw += PI_2; + else if (dYaw > PI) dYaw -= PI_2; + + return dYaw; + } + + function updatePosition(dt: number) { + // bot.isAlive = true // TODO: MOVE TO HEALTH + // If you're dead, you're probably on the ground though ... + if (!bot.isAlive) bot.entity.onGround = true; + + // Increment the yaw in baby steps so that notchian clients (not the server) can keep up. + const dYaw = deltaYaw(bot.entity.yaw, lastSentYaw); + const dPitch = bot.entity.pitch - (lastSentPitch || 0); + + // Vanilla doesn't clamp yaw, so we don't want to do it either + const maxDeltaYaw = dt * physics.yawSpeed; + const maxDeltaPitch = dt * physics.pitchSpeed; + + lastSentYaw = (lastSentYaw ?? 0) + math.clamp(-maxDeltaYaw, dYaw, maxDeltaYaw); + lastSentPitch = (lastSentPitch ?? 0) + math.clamp(-maxDeltaPitch, dPitch, maxDeltaPitch); + + const yaw = Math.fround(conv.toNotchianYaw(lastSentYaw)); + const pitch = Math.fround(conv.toNotchianPitch(lastSentPitch)); + const position = bot.entity.position; + + if (!positionUpdateSentEveryTick) { + // in case with non-auth movement + // Only send a position update if necessary, select the appropriate packet + const positionUpdated = lastSent.x !== position.x || lastSent.y !== position.y || lastSent.z !== position.z; + // bot.isAlive = true // GET IT TO THE BOT + const lookUpdated = lastSent.yaw !== yaw || lastSent.pitch !== pitch; + + if ((positionUpdated || lookUpdated) && bot.isAlive) { + sendMovementUpdate(position, yaw, pitch); + } + } else { + sendMovementUpdate(position, yaw, pitch); + } + } + + bot.physics = physics; + + function getMetadataForFlag(flag: string, state: boolean) { + let metadata: any = { + key: 'flags', + type: 'long', + value: {}, + }; + + metadata.value[flag] = state; + return metadata; + } + + bot.setControlState = (control: string, state: boolean) => { + assert.ok(control in controlState, `invalid control: ${control}`); + assert.ok(typeof state === 'boolean', `invalid state: ${state}`); + if ((controlState as any)[control] === state) return; + (controlState as any)[control] = state; + if (control === 'jump' && state) { + bot.jumpQueued = true; + } + if (bot.registry.version['<=']('1.19.1')) { + // // version might be wrong + if (['sneak', 'sprint'].indexOf(control) !== -1) { + let packet: any = { + runtime_entity_id: bot.entity.id, + metadata: [getMetadataForFlag('sneaking', state)], + tick: 0, + }; + if (bot.registry.version['>=']('1.19.1')) { + // version might be wrong + packet['properties'] = { + ints: [], + floats: [], + }; + } + bot._client.write('set_entity_data', packet); + } + } + }; + + bot.getControlState = (control: string) => { + assert.ok(control in controlState, `invalid control: ${control}`); + return (controlState as any)[control]; + }; + + bot.clearControlStates = () => { + for (const control in controlState) { + bot.setControlState(control, false); + } + }; + + bot.controlState = {} as any; + + for (const control of Object.keys(controlState)) { + Object.defineProperty(bot.controlState, control, { + get() { + return (controlState as any)[control]; + }, + set(state) { + bot.setControlState(control, state); + return state; + }, + }); + } + + let lookingTask = createDoneTask(); + + bot.on('move', () => { + if (!lookingTask.done && Math.abs(deltaYaw(bot.entity.yaw, lastSentYaw)) < 0.001) { + lookingTask.finish(); + } + }); + + bot.look = async (yaw: number, pitch: number, force?: boolean) => { + force = true; + if (!lookingTask.done) { + lookingTask.finish(); // finish the previous one + } + lookingTask = createTask(); + + if (!bot.entity.headYaw) { + // needs a fix? + bot.entity.headYaw = 0; + } + + // this is done to bypass certain anticheat checks that detect the player's sensitivity + // by calculating the gcd of how much they move the mouse each tick + const sensitivity = conv.fromNotchianPitch(0.15); // this is equal to 100% sensitivity in vanilla + const yawChange = Math.round((yaw - bot.entity.yaw) / sensitivity) * sensitivity; + + const headYawChange = Math.round((yaw - bot.entity.headYaw) / sensitivity) * sensitivity; + const pitchChange = Math.round((pitch - bot.entity.pitch) / sensitivity) * sensitivity; + + if (yawChange === 0 && pitchChange === 0) { + return; + } + + if (force) { + bot.entity.yaw = yaw; + bot.entity.headYaw = yaw; + bot.entity.pitch = pitch; + + lastSentYaw = yaw; + lastSentPitch = pitch; + return; + } else { + bot.entity.yaw += yawChange; + bot.entity.headYaw += yawChange; + bot.entity.pitch += pitchChange; + } + + await lookingTask.promise; + }; + + bot.lookAt = async (point: Vec3, force?: boolean) => { + force = true; + const delta = point.minus(bot.entity.position.offset(0, bot.entity.height, 0)); + const yaw = Math.atan2(-delta.x, -delta.z); + const headYaw = Math.atan2(-delta.x, -delta.z); + const groundDistance = Math.sqrt(delta.x * delta.x + delta.z * delta.z); + const pitch = Math.atan2(delta.y, groundDistance); + await bot.look(yaw, pitch, force); + }; + + // player position and look (clientbound) server to client + const setPosition = (packet: any) => { + const packetId = BigInt(packet.runtime_id ?? packet.runtime_entity_id ?? 0); + const botId = BigInt(bot.entity?.id ?? 0); + if (packetId !== botId) { + bot.logger.debug(`move_player: ignoring, packet runtime_id=${packetId} != bot.entity.id=${botId}`); + return; + } + bot.logger.debug(`move_player: updating position, runtime_id=${packetId}`); + bot.entity.height = 1.62; + bot.entity.velocity.set(0, 0, 0); + + // If flag is set, then the corresponding value is relative, else it is absolute + const pos = bot.entity.position; + const position = packet.player_position ?? packet.position; + const start_game_packet = !!packet.player_position; + // Bedrock sends position + eye height (head position), so subtract height to get foot position + pos.set(position.x, position.y - bot.entity.height, position.z); + + const newYaw = packet.yaw ?? packet.rotation.z; + const newPitch = packet.pitch ?? packet.rotation.x; + bot.entity.yaw = newYaw; // conv.fromNotchianYaw(newYaw) + bot.entity.pitch = newPitch; // conv.fromNotchianPitch(newPitch) + bot.entity.onGround = false; // if pos delta Y == 0 -> on ground + + sendMovementUpdate(pos, newYaw, newPitch); + + shouldUsePhysics = true; + bot.entity.timeSinceOnGround = 0; + lastSentYaw = bot.entity.yaw; + if (start_game_packet) + bot._client.once('spawn', async (packet) => { + shouldUsePhysics = true; + if (doPhysicsTimer === null) { + await bot.waitForChunksToLoad(); + lastPhysicsFrameTime = performance.now(); + doPhysicsTimer = doPhysicsTimer ?? setInterval(doPhysics, PHYSICS_INTERVAL_MS); + } + }); + bot.emit('forcedMove'); + }; + + bot._client.on('move_player', setPosition); + bot._client.on('start_game', setPosition); + + bot.waitForTicks = async function (ticks: number) { + if (ticks <= 0) return; + await new Promise((resolve) => { + const tickListener = () => { + ticks--; + if (ticks === 0) { + bot.removeListener('physicsTick', tickListener); + resolve(); + } + }; + bot.on('physicsTick', tickListener); + }); + }; + + // bot.on('mount', () => { shouldUsePhysics = false }) + bot.on('respawn', () => { + shouldUsePhysics = false; + }); + + bot.on('spawn', async () => { + shouldUsePhysics = true; + if (doPhysicsTimer === null) { + await bot.waitForChunksToLoad(); + lastPhysicsFrameTime = performance.now(); + doPhysicsTimer = doPhysicsTimer ?? setInterval(doPhysics, PHYSICS_INTERVAL_MS); + } + }); + + bot.on('end', cleanup); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/rain.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/rain.mts new file mode 100644 index 0000000..050c1ad --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/rain.mts @@ -0,0 +1,22 @@ +import type { BedrockBot } from '../../index.js'; + +export default function inject(bot: BedrockBot) { + bot.isRaining = false; + bot.thunderState = 0; + bot.rainState = 0; + bot._client.on('level_event', (packet) => { + if (packet.event === 'start_rain') { + bot.isRaining = true; + bot.emit('rain'); + } else if (packet.event === 'stop_rain') { + bot.isRaining = false; + bot.emit('rain'); + } else if (packet.event === 'start_thunder') { + bot.thunderState = 1; // this value requires checking against java + bot.emit('weatherUpdate'); + } else if (packet.event === 'stop_thunder') { + bot.thunderState = 0; // this value requires checking against java + bot.emit('weatherUpdate'); + } + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/resource_pack.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/resource_pack.mts new file mode 100644 index 0000000..bfc7b7c --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/resource_pack.mts @@ -0,0 +1,15 @@ +import type { BedrockBot } from '../../index.js'; +import assert from 'assert'; + +export default function inject(bot: BedrockBot) { + function acceptResourcePack() { + assert(false, 'Not supported'); + } + + function denyResourcePack() { + assert(false, 'Not supported'); + } + + bot.acceptResourcePack = acceptResourcePack; + bot.denyResourcePack = denyResourcePack; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/scoreboard.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/scoreboard.mts new file mode 100644 index 0000000..a426344 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/scoreboard.mts @@ -0,0 +1,64 @@ +import type { BedrockBot } from '../../index.js'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +export default function inject(bot: BedrockBot) { + const ScoreBoard = require('../scoreboard')(bot); + const scoreboards: Record = {}; + + bot._client.on('set_display_objective', (packet) => { + //console.log(packet) + // const { name, position } = packet + // const scoreboard = scoreboards[name] + // + // if (scoreboard !== undefined) { + // bot.emit('scoreboardPosition', position, scoreboard, ScoreBoard.positions[position]) + // ScoreBoard.positions[position] = scoreboard + // } + // + // if (packet.action === 0) { + // const { name } = packet + // const scoreboard = new ScoreBoard(packet) + // scoreboards[name] = scoreboard + // + // bot.emit('scoreboardCreated', scoreboard) + // } + // + // if (packet.action === 2) { + // scoreboards[packet.name].setTitle(packet.displayText) + // bot.emit('scoreboardTitleChanged', scoreboards[packet.name]) + // } + }); + + bot._client.on('remove_objective', (packet) => { + //console.log(packet) + // bot.emit('scoreboardDeleted', scoreboards[packet.name]) + // delete scoreboards[packet.name] + }); + + bot._client.on('set_score', (packet) => { + //console.log(packet) + // const scoreboard = scoreboards[packet.scoreName] + // if (scoreboard !== undefined && packet.action === 0) { + // const updated = scoreboard.add(packet.itemName, packet.value) + // bot.emit('scoreUpdated', scoreboard, updated) + // } + // + // if (packet.action === 1) { + // if (scoreboard !== undefined) { + // const removed = scoreboard.remove(packet.itemName) + // return bot.emit('scoreRemoved', scoreboard, removed) + // } + // + // for (const sb of Object.values(scoreboards)) { + // if (packet.itemName in sb.itemsMap) { + // const removed = sb.remove(packet.itemName) + // return bot.emit('scoreRemoved', sb, removed) + // } + // } + // } + }); + + bot.scoreboards = scoreboards; + bot.scoreboard = ScoreBoard.positions; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/simple_inventory.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/simple_inventory.mts new file mode 100644 index 0000000..5f85273 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/simple_inventory.mts @@ -0,0 +1,264 @@ +import type { BedrockBot, EquipmentDestination } from '../../index.js'; +import itemLoader, { type Item } from 'prismarine-item'; +import assert from 'assert'; + +// In Bedrock Edition, hotbar is slots 0-8 in the inventory window +const QUICK_BAR_START = 0; +const QUICK_BAR_COUNT = 9; + +// Armor slot mappings (Java compatible for API consistency) +// Bedrock armor container has 4 slots (0-3): head, torso, legs, feet +// We use Java-compatible indices for prismarine-windows compatibility +const armorSlots: Record = { + head: 36, // armor slot 0 + torso: 37, // armor slot 1 + legs: 38, // armor slot 2 + feet: 39, // armor slot 3 + 'off-hand': 45, // offhand slot (Java compatible) +}; + +export default function inject(bot: BedrockBot) { + const Item = (itemLoader as any)(bot.registry) as typeof Item; + + async function equip(item: Item | number, destination: EquipmentDestination | null): Promise { + // Convert item ID to item object if needed + if (typeof item === 'number') { + item = bot.inventory.findInventoryItem(item); + } + if (item == null || typeof item !== 'object') { + throw new Error('Invalid item object in equip (item is null or typeof item is not object)'); + } + + // Default to hand if no destination specified + if (!destination || destination === null) { + destination = 'hand'; + } + + const sourceSlot = item.slot; + let destSlot = getDestSlot(destination); + + // Already in correct slot + if (sourceSlot === destSlot) { + return; + } + + // Equipping armor or offhand - just move directly + if (destination !== 'hand') { + await bot.moveSlotItem(sourceSlot, destSlot); + return; + } + + // Equipping to hand - check if item is already in hotbar + if (sourceSlot >= QUICK_BAR_START && sourceSlot < QUICK_BAR_START + QUICK_BAR_COUNT) { + // Item is in hotbar, just change selection + bot.setQuickBarSlot(sourceSlot - QUICK_BAR_START); + return; + } + + // Item is in inventory, need to move to hotbar + // Find empty hotbar slot + destSlot = bot.inventory.firstEmptySlotRange(QUICK_BAR_START, QUICK_BAR_START + QUICK_BAR_COUNT); + if (destSlot == null) { + // No empty slot - swap with currently selected hotbar slot + // This will place the inventory item in the hotbar and move the + // hotbar item to where the inventory item was + destSlot = QUICK_BAR_START + bot.quickBarSlot; + } + + // Move/swap the item to the hotbar slot + await bot.moveSlotItem(sourceSlot, destSlot); + // Select the destination slot as the new hand + bot.setQuickBarSlot(destSlot - QUICK_BAR_START); + } + async function unequip(destination: EquipmentDestination | null): Promise { + if (!destination) { + destination = 'hand'; + } + + if (destination === 'hand') { + await equipEmpty(); + } else { + await disrobe(destination); + } + } + + async function equipEmpty(): Promise { + // First, try to find an empty hotbar slot and select it + for (let i = 0; i < QUICK_BAR_COUNT; ++i) { + if (!bot.inventory.slots[QUICK_BAR_START + i]) { + bot.setQuickBarSlot(i); + return; + } + } + + // No empty hotbar slot, try to move held item to inventory + const emptySlot = bot.inventory.firstEmptyInventorySlot(false); // false = don't check hotbar first (we already did) + if (emptySlot === null) { + // No room in inventory, toss the item + if (bot.heldItem) { + await bot.tossStack(bot.heldItem); + } + return; + } + + // Move held item to empty inventory slot + const equipSlot = QUICK_BAR_START + bot.quickBarSlot; + if (bot.inventory.slots[equipSlot]) { + await bot.moveSlotItem(equipSlot, emptySlot); + } + } + + async function disrobe(destination: EquipmentDestination): Promise { + const destSlot = getDestSlot(destination); + const itemAtSlot = bot.inventory.slots[destSlot]; + + if (!itemAtSlot) { + return; // Nothing to unequip + } + + // Find an empty inventory slot to move the armor to + const emptySlot = bot.inventory.firstEmptyInventorySlot(); + if (emptySlot === null) { + // No room in inventory, toss the item + await bot.tossStack(itemAtSlot); + return; + } + + // Move armor piece to inventory + await bot.moveSlotItem(destSlot, emptySlot); + } + async function toss(itemType: number, metadata: number | null, count: number | null): Promise { + // Find items matching the type/metadata in inventory + const matchingItems = bot.inventory.slots.filter((item) => { + if (!item) return false; + if (item.type !== itemType) return false; + if (metadata != null && item.metadata !== metadata) return false; + return true; + }); + + if (matchingItems.length === 0) { + throw new Error(`No item with type ${itemType} found in inventory`); + } + + let remaining = count ?? 1; + + for (const item of matchingItems) { + if (remaining <= 0) break; + + const tossCount = Math.min(remaining, item.count); + + // Send inventory_transaction for dropping items + bot._client.write('inventory_transaction', { + transaction: { + legacy: { + legacy_request_id: 0, + legacy_transactions: [], + }, + transaction_type: 'normal', + actions: [ + { + source_type: 'world_interaction', + flags: 0, + slot: 0, + old_item: { network_id: 0 }, + new_item: Item.toNotch( + Object.assign(Object.create(Object.getPrototypeOf(item)), item, { + count: tossCount, + }), + 0 + ), + }, + { + source_type: 'container', + inventory_id: 'inventory', + slot: item.slot, + old_item: Item.toNotch(item, 0), + new_item: + item.count - tossCount > 0 + ? Item.toNotch( + Object.assign(Object.create(Object.getPrototypeOf(item)), item, { + count: item.count - tossCount, + }), + 0 + ) + : { network_id: 0 }, + }, + ], + }, + }); + + // Update local inventory state + if (item.count - tossCount > 0) { + item.count -= tossCount; + bot.inventory.updateSlot(item.slot, item); + } else { + bot.inventory.updateSlot(item.slot, null); + } + + remaining -= tossCount; + } + } + async function tossStack(item: Item): Promise { + assert.ok(item, 'Item is required for tossStack'); + + // Open inventory if not already open + if (!bot.currentWindow) { + await bot.openInventory(); + } + + // Step 1: Take item to cursor + await bot.clickWindow(item.slot, 0, 0); + + // Step 2: Drop from cursor (slot -999 triggers drop action) + await bot.clickWindow(-999, 0, 0); + + // Close inventory + if (bot.currentWindow) { + bot.closeWindow(bot.currentWindow); + } + } + function setQuickBarSlot(slot: number): void { + assert.ok(slot >= 0 && slot < 9, `Invalid quickBarSlot: ${slot}`); + if (bot.quickBarSlot === slot) return; // Already selected + + bot.quickBarSlot = slot; + + const selectedSlot = QUICK_BAR_START + slot; + const hotbarItem = bot.inventory.slots[selectedSlot]; + + bot._client.write('mob_equipment', { + runtime_entity_id: bot.entity.id, + item: hotbarItem ? Item.toNotch(hotbarItem, 0) : { network_id: 0 }, + slot: selectedSlot, + selected_slot: selectedSlot, + window_id: 'inventory', + }); + + bot.updateHeldItem(); + } + function getDestSlot(destination: string): number { + if (destination === 'hand') { + return QUICK_BAR_START + bot.quickBarSlot; + } + const destSlot = armorSlots[destination]; + assert.ok(destSlot != null, `invalid destination: ${destination}`); + return destSlot; + } + + async function leftMouse(slot: number): Promise { + return bot.clickWindow(slot, 0, 0); + } + + async function rightMouse(slot: number): Promise { + return bot.clickWindow(slot, 1, 0); + } + + bot.equip = equip; + bot.unequip = unequip; + bot.toss = toss; + bot.tossStack = tossStack; + bot.setQuickBarSlot = setQuickBarSlot; + bot.getEquipmentDestSlot = getDestSlot; + bot.simpleClick = { leftMouse, rightMouse }; + bot.QUICK_BAR_START = QUICK_BAR_START; // 0 for Bedrock (36 for Java) +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/sound.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/sound.mts new file mode 100644 index 0000000..b81a136 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/sound.mts @@ -0,0 +1,31 @@ +import type { BedrockBot } from '../../index.js'; + +export default function inject(bot: BedrockBot) { + bot._client.on('play_sound', (packet) => { + const soundName = packet.name; + const volume = packet.volume; + const pitch = packet.pitch; + + bot.emit('soundEffectHeard', soundName, packet.coordinates, volume, pitch); + }); + + bot._client.on('level_sound_event', (packet) => { + const soundId = packet.sound_id; // diff field type + const soundCategory = packet.extra_data; // diff field type + const volume = 1; + const pitch = 1; + + bot.emit('hardcodedSoundEffectHeard', soundId, soundCategory, packet.position, volume, pitch); + }); + + bot._client.on('level_event', (packet) => { + if (packet.event.indexOf('sound') !== -1) { + const soundId = packet.event; // diff field type + const soundCategory = packet.data; // diff field type + const volume = 1; + const pitch = 1; + + bot.emit('hardcodedSoundEffectHeard', soundId, soundCategory, packet.position, volume, pitch); + } + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/spawn_point.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/spawn_point.mts new file mode 100644 index 0000000..ccdca67 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/spawn_point.mts @@ -0,0 +1,17 @@ +import type { BedrockBot } from '../../index.js'; +import { Vec3 } from 'vec3'; + +export default function inject(bot: BedrockBot) { + bot.spawnPoint = new Vec3(0, 0, 0); + bot._client.on('set_spawn_position', (packet) => { + if (packet.spawn_type === 'player') { + bot.spawnPoint = new Vec3(packet.player_position.x, packet.player_position.y, packet.player_position.z); + } + // else if (packet.spawn_type === "world") { + // if(bot.spawnPoint === new Vec3(0, 0, 0)) { + // bot.spawnPoint = new Vec3(packet.world_position.x, packet.world_position.y, packet.world_position.z) + // } + // } + bot.emit('game'); + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/tablist.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/tablist.mts new file mode 100644 index 0000000..ef8ea6e --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/tablist.mts @@ -0,0 +1,12 @@ +import type { BedrockBot } from '../../index.js'; +import { createRequire } from 'module'; +const require = createRequire(import.meta.url); + +export default function inject(bot: BedrockBot) { + const ChatMessage = require('prismarine-chat')(bot.registry); + + bot.tablist = { + header: new ChatMessage(''), + footer: new ChatMessage(''), + }; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/team.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/team.mts new file mode 100644 index 0000000..c01b326 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/team.mts @@ -0,0 +1,7 @@ +import type { BedrockBot } from '../../index.js'; + +export default function inject(bot: BedrockBot) { + // Unsupported in bedrock + bot.teams = {}; + bot.teamMap = {}; +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/time.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/time.mts new file mode 100644 index 0000000..bd41c39 --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/time.mts @@ -0,0 +1,63 @@ +import type { BedrockBot } from '../../index.js'; + +function longToBigInt(arr: bigint | number | number[]): bigint { + if (typeof arr === 'bigint') return arr; + if (typeof arr === 'number') return BigInt(arr); + if (Array.isArray(arr)) { + return BigInt.asIntN(64, BigInt(arr[0]) << 32n) | BigInt(arr[1]); + } + return 0n; +} + +export default function inject(bot: BedrockBot) { + bot.time = { + doDaylightCycle: null, + bigTime: null, + time: null, + timeOfDay: null, + day: null, + isDay: null, + moonPhase: null, + bigAge: null, + age: null, + }; + + // Initialize age from start_game.current_tick (world tick count) + bot._client.on('start_game', (packet) => { + if (packet.current_tick != null) { + const age = longToBigInt(packet.current_tick); + bot.time.bigAge = age; + bot.time.age = Number(age); + } + }); + + // Update age from tick_sync.response_time (server's current tick) + bot._client.on('tick_sync', (packet) => { + if (packet.response_time != null && packet.response_time !== 0n) { + const age = BigInt(packet.response_time); + bot.time.bigAge = age; + bot.time.age = Number(age); + } + }); + + bot._client.on('set_time', (packet) => { + let time = BigInt(packet.time); + + if (time < 0n) { + bot.time.doDaylightCycle = false; + time = -time; + } else { + bot.time.doDaylightCycle = true; + } + + bot.time.bigTime = time; + bot.time.time = Number(time); + bot.time.timeOfDay = bot.time.time % 24000; + bot.time.day = Math.floor(bot.time.time / 24000); + // Match Java: isDay when timeOfDay is in range [0, 13000) + bot.time.isDay = bot.time.timeOfDay >= 0 && bot.time.timeOfDay < 13000; + bot.time.moonPhase = bot.time.day % 8; + + bot.emit('time'); + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/title.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/title.mts new file mode 100644 index 0000000..d4cf7ba --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/title.mts @@ -0,0 +1,12 @@ +import type { BedrockBot } from '../../index.js'; + +export default function inject(bot: BedrockBot) { + bot._client.on('set_title', (packet) => { + if (packet.type === 'set_title') { + bot.emit('title', packet.text, 'title'); + } + if (packet.type === 'set_subtitle') { + bot.emit('title', packet.text, 'subtitle'); + } + }); +} diff --git a/bridge/lib/mineflayer/lib/bedrockPlugins/villager.mts b/bridge/lib/mineflayer/lib/bedrockPlugins/villager.mts new file mode 100644 index 0000000..783fe0b --- /dev/null +++ b/bridge/lib/mineflayer/lib/bedrockPlugins/villager.mts @@ -0,0 +1,449 @@ +/** + * Villager Plugin - Bedrock Edition implementation for villager trading + * + * Provides: + * - bot.openVillager(villagerEntity) - Opens trading UI and returns Villager window + * - bot.trade(villager, index, count) - Execute a trade + * + * Bedrock Protocol: + * - Open trade: interact {action_id: "open_inventory", target_entity_id} + * - Server responds: container_open + update_trade + * - Execute trade: item_stack_request with craft_recipe action + * - Close: container_close + * + * Trade data comes via update_trade packet with NBT structure: + * offers.value.Recipes.value.value = [ + * { buyA, buyB?, sell, buyCountA, maxUses, uses, tier, traderExp, netId, ... } + * ] + */ + +import type { Entity } from 'prismarine-entity'; +import type { Window } from 'prismarine-windows'; +import type { BedrockBot, VillagerTrade, Villager } from '../../index.js'; +import itemLoader, { type Item } from 'prismarine-item'; +import { EventEmitter } from 'events'; + +import { + actions, + getNextItemStackRequestId, + getStackId, + sendRequest, + waitForResponse, + ContainerIds, + slot as makeSlot, + type SlotLocation, +} from '../bedrock/index.mts'; + +// NBT item structure from update_trade packet +interface NbtItem { + type: string; + value: { + Name?: { value: string }; + Count?: { value: number }; + Damage?: { value: number }; + Block?: { value: { name?: { value: string } } }; + }; +} + +// Trade recipe from update_trade packet +interface NbtTradeRecipe { + buyA?: NbtItem; + buyB?: NbtItem; + sell?: NbtItem; + buyCountA?: { value: number }; + buyCountB?: { value: number }; + maxUses?: { value: number }; + uses?: { value: number }; + tier?: { value: number }; + traderExp?: { value: number }; + netId?: { value: number }; + priceMultiplierA?: { value: number }; + priceMultiplierB?: { value: number }; + demand?: { value: number }; + rewardExp?: { value: number }; +} + +// update_trade packet structure +interface UpdateTradePacket { + window_id: number; + window_type: string; + size: number; + trade_tier: number; + villager_unique_id: bigint | string; + entity_unique_id?: bigint | string; + display_name?: string; + new_trading_ui?: boolean; + economic_trades?: boolean; + offers?: { + type: string; + value: { + Recipes?: { + type: string; + value: { + type: string; + value: NbtTradeRecipe[]; + }; + }; + TierExpRequirements?: any; + }; + }; +} + +export default function inject(bot: BedrockBot) { + const Item = (itemLoader as any)(bot.registry) as typeof import('prismarine-item').Item; + + // Track active trade sessions + let activeTradeWindowId: number | null = null; + let activeVillagerEntityId: number | bigint | null = null; + + /** + * Parse NBT item to prismarine-item + */ + function parseNbtItem(nbtItem: NbtItem | undefined): Item | null { + if (!nbtItem?.value?.Name?.value) return null; + + const name = String(nbtItem.value.Name.value).replace('minecraft:', ''); + const count = nbtItem.value.Count?.value ?? 1; + const damage = nbtItem.value.Damage?.value ?? 0; + + // Look up item in registry + const itemDef = bot.registry.itemsByName[name]; + if (!itemDef) { + bot.logger.warn(`Unknown item in trade: ${name}`); + return null; + } + + return new Item(itemDef.id, count, damage); + } + + /** + * Parse trades from update_trade packet + */ + function parseTrades(packet: UpdateTradePacket): VillagerTrade[] { + const trades: VillagerTrade[] = []; + + const recipes = packet.offers?.value?.Recipes?.value?.value; + if (!Array.isArray(recipes)) { + bot.logger.warn('No recipes found in update_trade packet'); + return trades; + } + + for (const recipe of recipes) { + const inputItem1 = parseNbtItem(recipe.buyA); + const inputItem2 = parseNbtItem(recipe.buyB); + const outputItem = parseNbtItem(recipe.sell); + + if (!inputItem1 || !outputItem) { + bot.logger.warn('Invalid trade recipe - missing input or output'); + continue; + } + + // Calculate real price based on demand and price multiplier + const baseCost = recipe.buyCountA?.value ?? inputItem1.count; + const demand = recipe.demand?.value ?? 0; + const priceMultiplier = recipe.priceMultiplierA?.value ?? 0.05; + const demandDiff = Math.max(0, Math.floor(baseCost * demand * priceMultiplier)); + const realPrice = Math.min(Math.max(baseCost + demandDiff, 1), inputItem1.stackSize || 64); + + // Override inputItem1 count with buyCountA if specified + if (recipe.buyCountA?.value) { + inputItem1.count = recipe.buyCountA.value; + } + + const trade: VillagerTrade = { + inputItem1, + inputItem2: inputItem2 ?? null, + outputItem, + hasItem2: inputItem2 !== null && inputItem2.count > 0, + tradeDisabled: (recipe.uses?.value ?? 0) >= (recipe.maxUses?.value ?? 1), + nbTradeUses: recipe.uses?.value ?? 0, + maximumNbTradeUses: recipe.maxUses?.value ?? 1, + xp: recipe.traderExp?.value, + demand: recipe.demand?.value, + priceMultiplier: recipe.priceMultiplierA?.value, + realPrice, + // Bedrock-specific fields + tier: recipe.tier?.value, + netId: recipe.netId?.value, + rewardExp: recipe.rewardExp?.value, + }; + + trades.push(trade); + } + + return trades; + } + + /** + * Wait for update_trade packet + */ + function waitForTradeData(windowId: number, timeout = 5000): Promise { + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + bot._client.removeListener('update_trade', handler); + reject(new Error('Timeout waiting for trade data')); + }, timeout); + + const handler = (packet: UpdateTradePacket) => { + if (packet.window_id === windowId) { + clearTimeout(timer); + bot._client.removeListener('update_trade', handler); + const trades = parseTrades(packet); + resolve(trades); + } + }; + + bot._client.on('update_trade', handler); + }); + } + + /** + * Open villager trading window + * + * @param villagerEntity - The villager entity to trade with + * @returns Villager window with trades + */ + async function openVillager(villagerEntity: Entity): Promise { + // Verify entity is a villager or wandering trader + const entityType = String(villagerEntity.type || villagerEntity.name || ''); + if (!entityType.includes('villager') && !entityType.includes('wandering_trader')) { + throw new Error(`Entity is not a villager or trader: ${entityType}`); + } + + // Send interact packet to open trade window + bot._client.write('interact', { + action_id: 'open_inventory', + target_entity_id: villagerEntity.id, + has_position: false, + }); + + // Wait for window to open + const windowPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + bot.removeListener('windowOpen', onWindowOpen); + reject(new Error('Timeout waiting for trade window to open')); + }, 10000); + + const onWindowOpen = (window: Window) => { + clearTimeout(timeout); + resolve(window); + }; + + bot.once('windowOpen', onWindowOpen); + }); + + const window = await windowPromise; + activeTradeWindowId = window.id; + activeVillagerEntityId = villagerEntity.id; + + // Wait for trade data + const trades = await waitForTradeData(window.id); + + // Create Villager object extending the window + const villager = Object.assign(window, { + trades, + selectedTrade: null as VillagerTrade | null, + trade: async (index: number, count?: number) => { + await trade(villager as any, index, count); + }, + }) as unknown as Villager; + + // Emit ready event + (villager as any).emit('ready'); + + return villager; + } + + /** + * Execute a trade with a villager + * + * @param villager - The villager window (from openVillager) + * @param index - Trade index to execute + * @param count - Number of times to execute trade (default: max available) + */ + async function trade(villager: Villager, index: number | string, count?: number): Promise { + const tradeIndex = typeof index === 'string' ? parseInt(index, 10) : index; + + if (!villager.trades || tradeIndex < 0 || tradeIndex >= villager.trades.length) { + throw new Error(`Invalid trade index: ${tradeIndex}`); + } + + const tradeData = villager.trades[tradeIndex]; + villager.selectedTrade = tradeData; + + // Calculate available trades + const availableTrades = tradeData.maximumNbTradeUses - tradeData.nbTradeUses; + if (availableTrades <= 0) { + throw new Error('Trade is disabled (max uses reached)'); + } + + const timesToTrade = count ?? availableTrades; + if (timesToTrade > availableTrades) { + throw new Error(`Cannot trade ${timesToTrade} times, only ${availableTrades} available`); + } + + // Check if we have enough items + const realPrice = tradeData.realPrice ?? tradeData.inputItem1.count; + const neededItem1 = realPrice * timesToTrade; + const itemCount1 = countItems(tradeData.inputItem1.type, tradeData.inputItem1.metadata); + + if (itemCount1 < neededItem1) { + throw new Error(`Not enough ${tradeData.inputItem1.name} to trade (need ${neededItem1}, have ${itemCount1})`); + } + + if (tradeData.hasItem2 && tradeData.inputItem2) { + const neededItem2 = tradeData.inputItem2.count * timesToTrade; + const itemCount2 = countItems(tradeData.inputItem2.type, tradeData.inputItem2.metadata); + if (itemCount2 < neededItem2) { + throw new Error(`Not enough ${tradeData.inputItem2.name} to trade (need ${neededItem2}, have ${itemCount2})`); + } + } + + // Execute trade using item_stack_request + for (let i = 0; i < timesToTrade; i++) { + await executeOneTrade(villager, tradeData); + tradeData.nbTradeUses++; + if (tradeData.nbTradeUses >= tradeData.maximumNbTradeUses) { + tradeData.tradeDisabled = true; + } + } + } + + /** + * Count items in inventory of given type + */ + function countItems(itemType: number, metadata?: number | null): number { + let count = 0; + for (const item of bot.inventory.slots) { + if (item && item.type === itemType) { + if (metadata === null || metadata === undefined || item.metadata === metadata) { + count += item.count; + } + } + } + return count; + } + + /** + * Find inventory slot with item + */ + function findItemSlot(itemType: number, metadata?: number | null, minCount = 1): number | null { + for (let i = 0; i < bot.inventory.slots.length; i++) { + const item = bot.inventory.slots[i]; + if (item && item.type === itemType && item.count >= minCount) { + if (metadata === null || metadata === undefined || item.metadata === metadata) { + return i; + } + } + } + return null; + } + + /** + * Execute a single trade transaction + * + * Bedrock trade execution uses item_stack_request with: + * 1. place actions to put items in trade input slots + * 2. craft_recipe or trade action + * 3. take action to get output + */ + async function executeOneTrade(villager: Villager, tradeData: VillagerTrade): Promise { + const netId = (tradeData as any).netId; + if (!netId) { + throw new Error('Trade missing network ID - cannot execute'); + } + + // Find items in inventory + const realPrice = tradeData.realPrice ?? tradeData.inputItem1.count; + const slot1 = findItemSlot(tradeData.inputItem1.type, tradeData.inputItem1.metadata, realPrice); + if (slot1 === null) { + throw new Error(`Cannot find ${tradeData.inputItem1.name} in inventory`); + } + + const item1 = bot.inventory.slots[slot1]!; + const stackId1 = getStackId(item1); + + let slot2: number | null = null; + let item2: Item | null = null; + let stackId2 = 0; + + if (tradeData.hasItem2 && tradeData.inputItem2) { + slot2 = findItemSlot(tradeData.inputItem2.type, tradeData.inputItem2.metadata, tradeData.inputItem2.count); + if (slot2 === null) { + throw new Error(`Cannot find ${tradeData.inputItem2.name} in inventory`); + } + item2 = bot.inventory.slots[slot2]!; + stackId2 = getStackId(item2); + } + + // Find empty slot for output + let outputSlot = -1; + for (let i = 0; i < 36; i++) { + if (!bot.inventory.slots[i]) { + outputSlot = i; + break; + } + } + if (outputSlot === -1) { + throw new Error('No empty inventory slot for trade output'); + } + + // Build trade request + // Trade slots: 0 = input1, 1 = input2, 2 = output + const requestId = getNextItemStackRequestId(); + const outputCount = tradeData.outputItem.count; + + // Build result items for results_deprecated + const resultItems = [ + { + network_id: tradeData.outputItem.type, + count: outputCount, + metadata: tradeData.outputItem.metadata ?? 0, + block_runtime_id: 0, + extra: { has_nbt: 0, can_place_on: [], can_destroy: [] }, + }, + ]; + + // Use craft_recipe action with consume and place actions + // From packet captures, villager trades use similar format to crafting + const builder = actions() + .craftRecipe(netId, 1) + .resultsDeprecated(resultItems, 1) + .consume(realPrice, makeSlot('hotbar_and_inventory', slot1, stackId1)); + + if (tradeData.hasItem2 && tradeData.inputItem2 && slot2 !== null) { + builder.consume(tradeData.inputItem2.count, makeSlot('hotbar_and_inventory', slot2, stackId2)); + } + + // Place output directly to inventory + builder.place(outputCount, makeSlot('creative_output', 50, requestId), makeSlot('hotbar_and_inventory', outputSlot, 0)); + + bot.logger.debug(`Executing trade: netId=${netId}, requestId=${requestId}`); + + sendRequest(bot, requestId, builder.build()); + + const success = await waitForResponse(bot, requestId); + if (!success) { + throw new Error('Trade failed - server rejected request'); + } + + // Create output item in destination slot + const newItem = new Item(tradeData.outputItem.type, outputCount, tradeData.outputItem.metadata ?? 0); + (newItem as any).stackId = requestId; + bot.inventory.updateSlot(outputSlot, newItem); + + bot.logger.debug(`Trade successful: ${tradeData.outputItem.name} x${outputCount} → slot ${outputSlot}`); + } + + // Listen for trade window close + bot._client.on('container_close', (packet: { window_id: number }) => { + if (packet.window_id === activeTradeWindowId) { + activeTradeWindowId = null; + activeVillagerEntityId = null; + } + }); + + // Expose API + bot.openVillager = openVillager; + bot.trade = trade; +} diff --git a/bridge/lib/mineflayer/lib/bossbar.js b/bridge/lib/mineflayer/lib/bossbar.js new file mode 100644 index 0000000..c870eea --- /dev/null +++ b/bridge/lib/mineflayer/lib/bossbar.js @@ -0,0 +1,109 @@ +const colors = ['pink', 'blue', 'red', 'green', 'yellow', 'purple', 'white'] +const divisions = [0, 6, 10, 12, 20] + +function loader (registry) { + const ChatMessage = require('prismarine-chat')(registry) + return class BossBar { + constructor (uuid, title, health, dividers, color, flags) { + this._entityUUID = uuid + this.title = title + this._health = health + this._dividers = divisions[dividers] + this._color = colors[color] + this._shouldDarkenSky = flags & 0x1 + this._isDragonBar = flags & 0x2 + this._createFog = flags & 0x4 + } + + set entityUUID (uuid) { + this._entityUUID = uuid + } + + set title (title) { + if (title && typeof title === 'object' && title.type === 'string' && 'value' in title) { + this._title = title.value + } else { + const chatMsg = ChatMessage.fromNotch(title) + if (chatMsg !== undefined && chatMsg !== null) { + this._title = chatMsg + } else if (typeof title === 'string') { + this._title = title + } else { + this._title = '' + } + } + } + + set health (health) { + this._health = health + } + + set dividers (dividers) { + this._dividers = divisions[dividers] + } + + set color (color) { + this._color = colors[color] + } + + set flags (flags) { + this._shouldDarkenSky = flags & 0x1 + this._isDragonBar = flags & 0x2 + this._createFog = flags & 0x4 + } + + get flags () { + return (this._shouldDarkenSky) | (this._isDragonBar << 1) | (this._createFog << 2) + } + + set shouldDarkenSky (darkenSky) { + this._shouldDarkenSky = darkenSky + } + + set isDragonBar (dragonBar) { + this._isDragonBar = dragonBar + } + + get createFog () { + return this._createFog + } + + set createFog (createFog) { + this._createFog = createFog + } + + get entityUUID () { + return this._entityUUID + } + + get title () { + return this._title + } + + get health () { + return this._health + } + + get dividers () { + return this._dividers + } + + get color () { + return this._color + } + + get shouldDarkenSky () { + return this._shouldDarkenSky + } + + get isDragonBar () { + return this._isDragonBar + } + + get shouldCreateFog () { + return this._createFog + } + } +} + +module.exports = loader diff --git a/bridge/lib/mineflayer/lib/conversions.js b/bridge/lib/mineflayer/lib/conversions.js new file mode 100644 index 0000000..a601a48 --- /dev/null +++ b/bridge/lib/mineflayer/lib/conversions.js @@ -0,0 +1,40 @@ +const { Vec3 } = require('vec3') +const math = require('./math') +const euclideanMod = math.euclideanMod +const PI = Math.PI +const PI_2 = Math.PI * 2 +const TO_RAD = PI / 180 +const TO_DEG = 1 / TO_RAD +const FROM_NOTCH_BYTE = 360 / 256 +// From minecraft.wiki: Velocity is believed to be in units of 1/8000 of a block per server tick (50ms) +const FROM_NOTCH_VEL = 1 / 8000 + +exports.toRadians = toRadians +exports.toDegrees = toDegrees +exports.fromNotchianYaw = fromNotchianYaw +exports.fromNotchianPitch = fromNotchianPitch +exports.fromNotchVelocity = fromNotchVelocity +exports.toNotchianYaw = yaw => toDegrees(PI - yaw) +exports.toNotchianPitch = pitch => toDegrees(-pitch) +exports.fromNotchianYawByte = yaw => fromNotchianYaw(yaw * FROM_NOTCH_BYTE) +exports.fromNotchianPitchByte = pitch => fromNotchianPitch(pitch * FROM_NOTCH_BYTE) + +function toRadians (degrees) { + return TO_RAD * degrees +} + +function toDegrees (radians) { + return TO_DEG * radians +} + +function fromNotchianYaw (yaw) { + return euclideanMod(PI - toRadians(yaw), PI_2) +} + +function fromNotchianPitch (pitch) { + return euclideanMod(toRadians(-pitch) + PI, PI_2) - PI +} + +function fromNotchVelocity (vel) { + return new Vec3(vel.x * FROM_NOTCH_VEL, vel.y * FROM_NOTCH_VEL, vel.z * FROM_NOTCH_VEL) +} diff --git a/bridge/lib/mineflayer/lib/inventory-packet-logger.mts b/bridge/lib/mineflayer/lib/inventory-packet-logger.mts new file mode 100644 index 0000000..340e261 --- /dev/null +++ b/bridge/lib/mineflayer/lib/inventory-packet-logger.mts @@ -0,0 +1,36 @@ +/** + * Interface for packet logging. Implement this to create custom packet loggers. + */ +export interface IPacketLogger { + /** + * Log a packet + * @param direction 'C' for client->server, 'S' for server->client + * @param name Packet name + * @param packet Packet data + */ + log(direction: 'C' | 'S', name: string, packet: any): void; + + /** + * Attach logger to a bot's client + * @param client The bot._client instance + */ + attachToBot(client: any): void; + + /** + * Set the registry for item name resolution + * @param registry The bot.registry instance + */ + setRegistry?(registry: any): void; + + /** + * Log a custom message/event for debugging + * @param msg Message text + * @param data Optional additional data + */ + message?(msg: string, data?: Record): void; + + /** + * Close the logger and flush any pending writes + */ + close(): void; +} diff --git a/bridge/lib/mineflayer/lib/loader.js b/bridge/lib/mineflayer/lib/loader.js new file mode 100644 index 0000000..62cdb3e --- /dev/null +++ b/bridge/lib/mineflayer/lib/loader.js @@ -0,0 +1,251 @@ +const { EventEmitter } = require('events') +const pluginLoader = require('./plugin_loader') +const { Logger, LogLevel, LoggerColors } = require('./logger/logger.mts') +const plugins = { + bed: require('./plugins/bed'), + title: require('./plugins/title'), + block_actions: require('./plugins/block_actions'), + blocks: require('./plugins/blocks'), + book: require('./plugins/book'), + boss_bar: require('./plugins/boss_bar'), + breath: require('./plugins/breath'), + chat: require('./plugins/chat'), + chest: require('./plugins/chest'), + command_block: require('./plugins/command_block'), + craft: require('./plugins/craft'), + creative: require('./plugins/creative'), + digging: require('./plugins/digging'), + enchantment_table: require('./plugins/enchantment_table'), + entities: require('./plugins/entities'), + experience: require('./plugins/experience'), + explosion: require('./plugins/explosion'), + fishing: require('./plugins/fishing'), + furnace: require('./plugins/furnace'), + game: require('./plugins/game'), + health: require('./plugins/health'), + inventory: require('./plugins/inventory'), + kick: require('./plugins/kick'), + physics: require('./plugins/physics'), + place_block: require('./plugins/place_block'), + rain: require('./plugins/rain'), + ray_trace: require('./plugins/ray_trace'), + resource_pack: require('./plugins/resource_pack'), + scoreboard: require('./plugins/scoreboard'), + team: require('./plugins/team'), + settings: require('./plugins/settings'), + simple_inventory: require('./plugins/simple_inventory'), + sound: require('./plugins/sound'), + spawn_point: require('./plugins/spawn_point'), + tablist: require('./plugins/tablist'), + time: require('./plugins/time'), + villager: require('./plugins/villager'), + anvil: require('./plugins/anvil'), + place_entity: require('./plugins/place_entity'), + generic_place: require('./plugins/generic_place'), + particle: require('./plugins/particle') +} + +const bedrockPlugins = { + bed: require('./bedrockPlugins/bed.mts').default, // 100% - sleep/wake with animate packet + title: require('./bedrockPlugins/title.mts').default, // 100% + + block_actions: require('./bedrockPlugins/block_actions.mts').default, // // 40% destroyStage unavalible for bedrock, calculates client-side, piston and noteblocks fix req + blocks: require('./bedrockPlugins/blocks.mts').default, // 80% WIP WORLD LOADER (block entities etc) doors bbs calc wrong (wrong state calc?) + + book: require('./bedrockPlugins/book.mts').default, // 100% - writeBook/signBook via book_edit packet + boss_bar: require('./bedrockPlugins/bossbar.mts').default, // 0% - 80% possible 100% + + breath: require('./bedrockPlugins/breath.mts').default, // 100% + chat: require('./bedrockPlugins/chat.mts').default, // 100% - fixed text and command_request packets for 1.21.130 + + chest: require('./bedrockPlugins/chest.mts').default, // 100% - openContainer/openChest/openDispenser + //command_block: require('./bedrockPlugins/command_block'), // 0% req inventory + craft: require('./bedrockPlugins/craft.mts').default, // 100% - crafting via craft_recipe item_stack_request + creative: require('./bedrockPlugins/creative.mts').default, // partial - flying works, setInventorySlot pending (status 7 error) + digging: require('./bedrockPlugins/digging.mts').default, // 100% - uses player_auth_input block_action + //enchantment_table: require('./bedrockPlugins/enchantment_table'), // 0% req inv + + entities: require('./bedrockPlugins/entities.mts').default, // 100% working? (no item entities) yaw and pitch conversion req fix (pviewer rotates player too fast) + + experience: require('./bedrockPlugins/experience.mts').default, // 100% + // explosion: require('./bedrockPlugins/explosion'), // 0% - 90% req logical checks, tho maybe its calc by server? + fishing: require('./bedrockPlugins/fishing.mts').default, // 100% - uses entity_event fish_hook_hook + // furnace: require('./bedrockPlugins/furnace'), // 0% req inv + + game: require('./bedrockPlugins/game.mts').default, // 70% - 100% | works, req impl other stuff + health: require('./bedrockPlugins/health.mts').default, // 100% + + //inventory: require('./bedrockPlugins/inventory_minimal.mts').default, // placeholder way? + + inventory: require('./bedrockPlugins/inventory.mts').default, // 100% - all click modes (0-4 incl creative), containers, transfers, activateBlock/Entity/consume + kick: require('./bedrockPlugins/kick.mts').default, // 100% done + + physics: require('./bedrockPlugins/physics.mts').default, // req blocks_.js and minecraft-data update (for effects) + + //place_block: require('./bedrockPlugins/place_block'), // req player auth input logic + rain: require('./bedrockPlugins/rain.mts').default, // 100%, might require small check + + ray_trace: plugins.ray_trace, // 100% unchanged? HeadYaw implement? + + resource_pack: require('./bedrockPlugins/resource_pack.mts').default, // 100%. not needed since bedrock does not support/handled at login by bedrock-protocol + + scoreboard: require('./bedrockPlugins/scoreboard.mts').default, // badly implemented 10% 0 functions + + team: require('./bedrockPlugins/team.mts').default, // 0% req investigation + //settings: require('./bedrockPlugins/settings'), // 0% only SOME settings are exposed (better to only leave chunks) + simple_inventory: require('./bedrockPlugins/simple_inventory.mts').default, // 100% - equip/unequip/toss for all slots including armor and offhand + sound: require('./bedrockPlugins/sound.mts').default, // 100% + spawn_point: require('./bedrockPlugins/spawn_point.mts').default, // 100%, might require small logical checking + tablist: require('./bedrockPlugins/tablist.mts').default, // 0% bedrock does not have it but possible to make a conversion + + time: require('./bedrockPlugins/time.mts').default, // doesnt have AGE + + villager: require('./bedrockPlugins/villager.mts').default, // 80% - openVillager, trade with item_stack_request + //anvil: require('./bedrockPlugins/anvil'), // 0% req inv + //place_entity: require('./bedrockPlugins/place_entity'), // 0% - 80% | 100% possible + //generic_place: require('./bedrockPlugins/generic_place'), // 0% not sure why but possible + + particle: require('./bedrockPlugins/particle.mts').default // mostly works, tho needs to be unified a bit +} + +const minecraftData = require('minecraft-data') + +// TODO: how to export supported version if both bedrock and java supported? +const { testedVersions, latestSupportedVersion, oldestSupportedVersion } = require('./version')(false) + +const BEDROCK_PREFIX = 'bedrock_' + +module.exports = { + createBot, + Location: require('./location'), + Painting: require('./painting'), + ScoreBoard: require('./scoreboard'), + BossBar: require('./bossbar'), + Particle: require('./particle'), + Logger, + LogLevel, + LoggerColors, + latestSupportedVersion, + oldestSupportedVersion, + testedVersions, + supportFeature: (feature, version) => minecraftData(version).supportFeature(feature) +} + +function createBot (options = {}) { + options.isBedrock = options.version.indexOf(BEDROCK_PREFIX) !== -1 + options.username = options.username ?? 'Player' + options.version = options.version.replace(BEDROCK_PREFIX, '') ?? false + options.plugins = options.plugins ?? {} + options.hideErrors = options.hideErrors ?? false + options.logErrors = options.logErrors ?? true + options.loadInternalPlugins = options.loadInternalPlugins ?? true + options.client = options.client ?? null + options.brand = options.brand ?? 'vanilla' + options.respawn = options.respawn ?? true + options.fakeWorldPath = options.fakeWorldPath ?? null + options.offline = options.auth === 'offline' + options.packetLogger = options.packetLogger ?? null + + // Logger configuration + if (options.logLevel !== undefined) { + Logger.level = options.logLevel + } else if (options.debug) { + Logger.level = LogLevel.Debug + } + + const bot = new EventEmitter() + bot._client = options.client + bot.end = (reason) => bot._client.end(reason) + + // Create logger instance for this bot + bot.logger = new Logger(options.username, LoggerColors.Aqua) + + if (options.logErrors) { + bot.on('error', err => { + if (!options.hideErrors) { + bot.logger.error(err) + } + }) + } + + pluginLoader(bot, options) + let pluginsToLoad = options.isBedrock ? bedrockPlugins : plugins + + const internalPlugins = Object.keys(pluginsToLoad) + .filter(key => { + if (typeof options.plugins[key] === 'function') return false + if (options.plugins[key] === false) return false + return options.plugins[key] || options.loadInternalPlugins + }).map(key => pluginsToLoad[key]) + const externalPlugins = Object.keys(options.plugins) + .filter(key => { + return typeof options.plugins[key] === 'function' + }).map(key => options.plugins[key]) + bot.loadPlugins([...internalPlugins, ...externalPlugins]) + + let protocol = options.isBedrock ? require('bedrock-protocol') : require('minecraft-protocol') + + // Allow overriding protocol version for newer servers + const protocolOpts = { ...options }; + if (options.isBedrock && options.bedrockProtocolVersion) { + protocolOpts.version = options.bedrockProtocolVersion; + } + bot._client = bot._client ?? protocol.createClient(protocolOpts) + + // Packet logger setup - accepts IPacketLogger instance + if (options.packetLogger && options.isBedrock) { + const logger = options.packetLogger + if (logger && typeof logger.attachToBot === 'function') { + bot.packetLogger = logger + logger.attachToBot(bot._client) + bot.on('end', () => logger.close()) + // Set registry when available for item name resolution + bot.once('inject_allowed', () => { + if (typeof logger.setRegistry === 'function') { + logger.setRegistry(bot.registry) + } + }) + } + } + + bot._client.on('connect', () => { + bot.emit('connect') + }) + bot._client.on('error', (err) => { + bot.emit('error', err) + }) + bot._client.on('end', (reason) => { + bot.emit('end', reason) + }) + + bot._client.on('close', (reason) => { + bot.emit('end', reason) + }) + if (!bot._client.wait_connect) next() // unknown purpose + else bot._client.once('connect_allowed', next) + function next () { + const { testedVersions, latestSupportedVersion, oldestSupportedVersion } = require('./version')(options.isBedrock) + const serverPingVersion = options.isBedrock ? BEDROCK_PREFIX + options.version : bot._client.version + bot.registry = require('prismarine-registry')(serverPingVersion) + if (!bot.registry?.version) throw new Error(`Server version '${serverPingVersion}' is not supported, no data for version`) + + const versionData = bot.registry.version + if (versionData['>'](latestSupportedVersion)) { + throw new Error(`Server version '${serverPingVersion}' is not supported. Latest supported version is '${latestSupportedVersion}'.`) + } else if (versionData['<'](oldestSupportedVersion)) { + throw new Error(`Server version '${serverPingVersion}' is not supported. Oldest supported version is '${oldestSupportedVersion}'.`) + } + + bot.protocolVersion = versionData.version + bot.majorVersion = versionData.majorVersion + bot.version = options.isBedrock ? BEDROCK_PREFIX + versionData.minecraftVersion : versionData.minecraftVersion + bot.supportFeature = bot.registry.supportFeature + setTimeout(() => bot.emit('inject_allowed'), 0) + } + + bot.close = function(){ + bot._client?.close() + } + + return bot +} diff --git a/bridge/lib/mineflayer/lib/location.js b/bridge/lib/mineflayer/lib/location.js new file mode 100644 index 0000000..5c88f1c --- /dev/null +++ b/bridge/lib/mineflayer/lib/location.js @@ -0,0 +1,14 @@ +const { Vec3 } = require('vec3') +const CHUNK_SIZE = new Vec3(16, 16, 16) + +class Location { + constructor (absoluteVector) { + this.floored = absoluteVector.floored() + this.blockPoint = this.floored.modulus(CHUNK_SIZE) + this.chunkCorner = this.floored.minus(this.blockPoint) + this.blockIndex = this.blockPoint.x + CHUNK_SIZE.x * this.blockPoint.z + CHUNK_SIZE.x * CHUNK_SIZE.z * this.blockPoint.y + this.biomeBlockIndex = this.blockPoint.x + CHUNK_SIZE.x * this.blockPoint.z + this.chunkYIndex = Math.floor(absoluteVector.y / 16) + } +} +module.exports = Location diff --git a/bridge/lib/mineflayer/lib/logger/logger-colors.mts b/bridge/lib/mineflayer/lib/logger/logger-colors.mts new file mode 100644 index 0000000..ffa8932 --- /dev/null +++ b/bridge/lib/mineflayer/lib/logger/logger-colors.mts @@ -0,0 +1,36 @@ +/** + * ANSI color codes for terminal output. + * Adapted from @serenityjs/logger + */ +export const LoggerColors = { + Black: '\u001B[38;2;0;0;0m', + DarkBlue: '\u001B[38;2;0;0;170m', + DarkGreen: '\u001B[38;2;0;170;0m', + DarkAqua: '\u001B[38;2;0;170;170m', + DarkRed: '\u001B[38;2;170;0;0m', + DarkPurple: '\u001B[38;2;170;0;170m', + Gold: '\u001B[38;2;255;170;0m', + Gray: '\u001B[38;2;170;170;170m', + DarkGray: '\u001B[38;2;85;85;85m', + Blue: '\u001B[38;2;85;85;255m', + Green: '\u001B[38;2;85;255;85m', + Aqua: '\u001B[38;2;85;255;255m', + Red: '\u001B[38;2;255;85;85m', + LightPurple: '\u001B[38;2;255;85;255m', + Yellow: '\u001B[38;2;255;255;85m', + White: '\u001B[38;2;255;255;255m', + MinecoinGold: '\u001B[38;2;221;214;5m', + MaterialQuartz: '\u001B[38;2;227;212;209m', + MaterialIron: '\u001B[38;2;206;202;202m', + MaterialNetherite: '\u001B[38;2;68;58;59m', + MaterialRedstone: '\u001B[38;2;151;22;7m', + MaterialCopper: '\u001B[38;2;180;104;77m', + MaterialGold: '\u001B[38;2;222;177;45m', + MaterialEmerald: '\u001B[38;2;71;160;54m', + MaterialDiamond: '\u001B[38;2;44;186;168m', + MaterialLapis: '\u001B[38;2;33;73;123m', + MaterialAmethyst: '\u001B[38;2;154;92;198m', + Reset: '\u001B[0m', +} as const; + +export type LoggerColors = (typeof LoggerColors)[keyof typeof LoggerColors]; diff --git a/bridge/lib/mineflayer/lib/logger/logger.mts b/bridge/lib/mineflayer/lib/logger/logger.mts new file mode 100644 index 0000000..c5408a2 --- /dev/null +++ b/bridge/lib/mineflayer/lib/logger/logger.mts @@ -0,0 +1,172 @@ +/** + * A colorized logger for mineflayer bots. + * Adapted from @serenityjs/logger + */ +import { formatMinecraftColorCode } from './minecraft-colors.mts'; +import { LoggerColors } from './logger-colors.mts'; + +export { LoggerColors } from './logger-colors.mts'; +export { formatMinecraftColorCode, stripMinecraftColorCodes } from './minecraft-colors.mts'; + +/** + * Log level for filtering messages. + */ +export const LogLevel = { + Debug: 0, + Info: 1, + Warn: 2, + Error: 3, + None: 4, +} as const; + +export type LogLevel = (typeof LogLevel)[keyof typeof LogLevel]; + +/** + * Format a date as MM-DD-YYYY HH:mm:ss + */ +function formatTimestamp(date: Date = new Date()): string { + const pad = (n: number) => n.toString().padStart(2, '0'); + const month = pad(date.getMonth() + 1); + const day = pad(date.getDate()); + const year = date.getFullYear(); + const hours = pad(date.getHours()); + const minutes = pad(date.getMinutes()); + const seconds = pad(date.getSeconds()); + return `${month}-${day}-${year} ${hours}:${minutes}:${seconds}`; +} + +/** + * A colorized logger for applications. + */ +export class Logger { + /** + * Global log level. Messages below this level are not shown. + */ + public static level: LogLevel = LogLevel.Info; + + /** + * Whether debug messages should be shown (shortcut for level = Debug). + */ + public static get DEBUG(): boolean { + return Logger.level === LogLevel.Debug; + } + + public static set DEBUG(value: boolean) { + Logger.level = value ? LogLevel.Debug : LogLevel.Info; + } + + /** + * The module name of the logger. + */ + public readonly name: string; + + /** + * The color of module name. + */ + public readonly color: string; + + /** + * Constructs a new logger. + * @param name - The module name. + * @param color - The color of the module name (from LoggerColors). + */ + constructor(name: string, color: string = LoggerColors.White) { + this.name = name; + this.color = color; + } + + /** + * Creates the formatted prefix for log messages. + */ + private prefix(level?: string, levelColor?: string): string { + const timestamp = `${LoggerColors.DarkGray}<${LoggerColors.Reset}${formatTimestamp()}${LoggerColors.DarkGray}>`; + const module = `${LoggerColors.DarkGray}[${this.color}${this.name}${LoggerColors.DarkGray}]`; + + if (level && levelColor) { + const levelTag = `${LoggerColors.DarkGray}[${levelColor}${level}${LoggerColors.DarkGray}]`; + return `${timestamp} ${module} ${levelTag}${LoggerColors.Reset}`; + } + + return `${timestamp} ${module}${LoggerColors.Reset}`; + } + + /** + * Colorize arguments by converting Minecraft color codes. + */ + private colorize(...args: unknown[]): unknown[] { + return args.map((arg) => { + if (typeof arg === 'string') { + return formatMinecraftColorCode(arg); + } + return arg; + }); + } + + /** + * Logs a message to the console. + */ + public log(...args: unknown[]): void { + console.log(this.prefix(), ...this.colorize(...args)); + } + + /** + * Logs an info message to the console. + */ + public info(...args: unknown[]): void { + if (Logger.level > LogLevel.Info) return; + console.log(this.prefix('Info', LoggerColors.DarkAqua), ...this.colorize(...args)); + } + + /** + * Logs a warning message to the console. + */ + public warn(...args: unknown[]): void { + if (Logger.level > LogLevel.Warn) return; + console.log(this.prefix('Warning', LoggerColors.Yellow), ...this.colorize(...args)); + } + + /** + * Logs an error message to the console. + */ + public error(...args: unknown[]): void { + if (Logger.level > LogLevel.Error) return; + console.log(this.prefix('Error', LoggerColors.DarkRed), ...args); + } + + /** + * Logs a success message to the console. + */ + public success(...args: unknown[]): void { + if (Logger.level > LogLevel.Info) return; + console.log(this.prefix('Success', LoggerColors.Green), ...this.colorize(...args)); + } + + /** + * Logs a debug message to the console. + * Only shown when DEBUG is true or level is Debug. + */ + public debug(...args: unknown[]): void { + if (Logger.level > LogLevel.Debug) return; + console.log(this.prefix('DEBUG', LoggerColors.Red), ...this.colorize(...args)); + } + + /** + * Logs a chat message to the console. + */ + public chat(sender: string, message: string): void { + if (Logger.level > LogLevel.Info) return; + console.log(this.prefix('Chat', LoggerColors.DarkAqua), this.colorize(sender)[0], '>', this.colorize(message)[0]); + } + + /** + * Create a child logger with a sub-module name. + */ + public child(name: string, color?: string): Logger { + return new Logger(`${this.name}:${name}`, color ?? this.color); + } +} + +/** + * Default logger instance for the bot. + */ +export const defaultLogger = new Logger('Bot', LoggerColors.Aqua); diff --git a/bridge/lib/mineflayer/lib/logger/minecraft-colors.mts b/bridge/lib/mineflayer/lib/logger/minecraft-colors.mts new file mode 100644 index 0000000..1a76b9c --- /dev/null +++ b/bridge/lib/mineflayer/lib/logger/minecraft-colors.mts @@ -0,0 +1,60 @@ +/** + * Minecraft color code to ANSI escape sequence mapping. + * Converts Minecraft's § color codes to terminal colors. + * Adapted from @serenityjs/logger + */ +const ansiColors: Record = { + '0': '\u001B[38;2;0;0;0m', // Black + '1': '\u001B[38;2;0;0;170m', // Dark Blue + '2': '\u001B[38;2;0;170;0m', // Dark Green + '3': '\u001B[38;2;0;170;170m', // Dark Aqua + '4': '\u001B[38;2;170;0;0m', // Dark Red + '5': '\u001B[38;2;170;0;170m', // Dark Purple + '6': '\u001B[38;2;255;170;0m', // Gold + '7': '\u001B[38;2;170;170;170m', // Gray + '8': '\u001B[38;2;85;85;85m', // Dark Gray + '9': '\u001B[38;2;85;85;255m', // Blue + a: '\u001B[38;2;85;255;85m', // Green + b: '\u001B[38;2;85;255;255m', // Aqua + c: '\u001B[38;2;255;85;85m', // Red + d: '\u001B[38;2;255;85;255m', // Light Purple + e: '\u001B[38;2;255;255;85m', // Yellow + f: '\u001B[38;2;255;255;255m', // White + g: '\u001B[38;2;221;214;5m', // Minecoin Gold + h: '\u001B[38;2;227;212;209m', // Material Quartz + i: '\u001B[38;2;206;202;202m', // Material Iron + j: '\u001B[38;2;68;58;59m', // Material Netherite + m: '\u001B[38;2;151;22;7m', // Material Redstone + n: '\u001B[38;2;180;104;77m', // Material Copper + p: '\u001B[38;2;222;177;45m', // Material Gold + q: '\u001B[38;2;71;160;54m', // Material Emerald + r: '\u001B[0m', // Reset + s: '\u001B[38;2;44;186;168m', // Material Diamond + t: '\u001B[38;2;33;73;123m', // Material Lapis + u: '\u001B[38;2;154;92;198m', // Material Amethyst +}; + +const colorCodeRegex = /§[\da-fk-or-u]/g; + +/** + * Converts Minecraft color codes (§) to ANSI escape sequences. + * @param text - Text containing Minecraft color codes + * @returns Text with ANSI escape sequences + */ +export function formatMinecraftColorCode(text: string): string { + return ( + text.replace(colorCodeRegex, (match) => { + const code = match.charAt(1); + return ansiColors[code] || ''; + }) + '\u001B[0m' + ); +} + +/** + * Strips Minecraft color codes from text. + * @param text - Text containing Minecraft color codes + * @returns Plain text without color codes + */ +export function stripMinecraftColorCodes(text: string): string { + return text.replace(colorCodeRegex, ''); +} diff --git a/bridge/lib/mineflayer/lib/math.js b/bridge/lib/mineflayer/lib/math.js new file mode 100644 index 0000000..f7d53ce --- /dev/null +++ b/bridge/lib/mineflayer/lib/math.js @@ -0,0 +1,8 @@ +exports.clamp = function clamp (min, x, max) { + return Math.max(min, Math.min(x, max)) +} + +exports.euclideanMod = function euclideanMod (numerator, denominator) { + const result = numerator % denominator + return result < 0 ? result + denominator : result +} diff --git a/bridge/lib/mineflayer/lib/painting.js b/bridge/lib/mineflayer/lib/painting.js new file mode 100644 index 0000000..4efe4c5 --- /dev/null +++ b/bridge/lib/mineflayer/lib/painting.js @@ -0,0 +1,7 @@ +function Painting (id, pos, name, direction) { + this.id = id + this.position = pos + this.name = name + this.direction = direction +} +module.exports = Painting diff --git a/bridge/lib/mineflayer/lib/particle.js b/bridge/lib/mineflayer/lib/particle.js new file mode 100644 index 0000000..0c50fb0 --- /dev/null +++ b/bridge/lib/mineflayer/lib/particle.js @@ -0,0 +1,44 @@ +const { Vec3 } = require('vec3') + +module.exports = loader + +function loader (registry) { + class Particle { + constructor (id, position, offset, count = 1, movementSpeed = 0, longDistanceRender = false) { + this.id = id + + if(registry.particles) + Object.assign(this, registry.particles[id] || registry.particlesByName[id]) + this.position = position + this.offset = offset + this.count = count + this.movementSpeed = movementSpeed + this.longDistanceRender = longDistanceRender + } + + static fromNetwork (packet) { + if (registry.supportFeature('updatedParticlesPacket')) { + // TODO: We add extra data that's inside packet.particle.data that varies by the particle's .type + return new Particle( + packet.particle.type, + new Vec3(packet.x, packet.y, packet.z), + new Vec3(packet.offsetX, packet.offsetY, packet.offsetZ), + packet.amount, + packet.velocityOffset, + packet.longDistance + ) + } else { + return new Particle( + packet.particleId, + new Vec3(packet.x, packet.y, packet.z), + new Vec3(packet.offsetX, packet.offsetY, packet.offsetZ), + packet.particles, + packet.particleData, + packet.longDistance + ) + } + } + } + + return Particle +} diff --git a/bridge/lib/mineflayer/lib/plugin_loader.js b/bridge/lib/mineflayer/lib/plugin_loader.js new file mode 100644 index 0000000..97b5a04 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugin_loader.js @@ -0,0 +1,52 @@ +const assert = require('assert') + +module.exports = inject + +function inject (bot, options) { + let loaded = false + const pluginList = [] + bot.once('inject_allowed', onInjectAllowed) + + function onInjectAllowed () { + loaded = true + injectPlugins() + } + + function loadPlugin (plugin) { + assert.ok(typeof plugin === 'function', 'plugin needs to be a function') + + if (hasPlugin(plugin)) { + return + } + + pluginList.push(plugin) + + if (loaded) { + plugin(bot, options) + } + } + + function loadPlugins (plugins) { + // While type checking if already done in the other function, it's useful to do + // it here to prevent situations where only half the plugin list is loaded. + assert.ok(plugins.filter(plugin => typeof plugin === 'function').length === plugins.length, 'plugins need to be an array of functions') + + plugins.forEach((plugin) => { + loadPlugin(plugin) + }) + } + + function injectPlugins () { + pluginList.forEach((plugin) => { + plugin(bot, options) + }) + } + + function hasPlugin (plugin) { + return pluginList.indexOf(plugin) >= 0 + } + + bot.loadPlugin = loadPlugin + bot.loadPlugins = loadPlugins + bot.hasPlugin = hasPlugin +} diff --git a/bridge/lib/mineflayer/lib/plugins/anvil.js b/bridge/lib/mineflayer/lib/plugins/anvil.js new file mode 100644 index 0000000..9675a3a --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/anvil.js @@ -0,0 +1,115 @@ +const assert = require('assert') +const { sleep } = require('../promise_utils') +const { once } = require('../promise_utils') + +module.exports = inject + +function inject (bot) { + const Item = require('prismarine-item')(bot.registry) + + const matchWindowType = window => /minecraft:(?:chipped_|damaged_)?anvil/.test(window.type) + + async function openAnvil (anvilBlock) { + const anvil = await bot.openBlock(anvilBlock) + if (!matchWindowType(anvil)) { + throw new Error('Not a anvil-like window: ' + JSON.stringify(anvil)) + } + + function err (name) { + anvil.close() + throw new Error(name) + } + + function sendItemName (name) { + if (bot.supportFeature('useMCItemName')) { + bot._client.writeChannel('MC|ItemName', name) + } else { + bot._client.write('name_item', { name }) + } + } + + async function addCustomName (name) { + if (!name) return + for (let i = 1; i < name.length + 1; i++) { + sendItemName(name.substring(0, i)) + await sleep(50) + } + } + async function putInAnvil (itemOne, itemTwo) { + await putSomething(0, itemOne.type, itemOne.metadata, itemOne.count, itemOne.nbt) + sendItemName('') // sent like this by vnailla + if (!bot.supportFeature('useMCItemName')) sendItemName('') + await putSomething(1, itemTwo.type, itemTwo.metadata, itemTwo.count, itemTwo.nbt) + } + + async function combine (itemOne, itemTwo, name) { + if (name?.length > 35) err('Name is too long.') + if (bot.supportFeature('useMCItemName')) { + bot._client.registerChannel('MC|ItemName', 'string') + } + + assert.ok(itemOne && itemTwo) + const { xpCost: normalCost } = Item.anvil(itemOne, itemTwo, bot.game.gameMode === 'creative', name) + const { xpCost: inverseCost } = Item.anvil(itemTwo, itemOne, bot.game.gameMode === 'creative', name) + if (normalCost === 0 && inverseCost === 0) err('Not anvil-able (in either direction), cancelling.') + + const smallest = (normalCost < inverseCost ? normalCost : inverseCost) === 0 ? inverseCost : 0 + if (bot.game.gameMode !== 'creative' && bot.experience.level < smallest) { + err('Player does not have enough xp to do action, cancelling.') + } + + const xpPromise = bot.game.gameMode === 'creative' ? Promise.resolve() : once(bot, 'experience') + if (normalCost === 0) await putInAnvil(itemTwo, itemOne) + else if (inverseCost === 0) await putInAnvil(itemOne, itemTwo) + else if (normalCost < inverseCost) await putInAnvil(itemOne, itemTwo) + else await putInAnvil(itemTwo, itemOne) + + await addCustomName(name) + await bot.putAway(2) + await xpPromise + } + + async function rename (item, name) { + if (name?.length > 35) err('Name is too long.') + if (bot.supportFeature('useMCItemName')) { + bot._client.registerChannel('MC|ItemName', 'string') + } + assert.ok(item) + const { xpCost: normalCost } = Item.anvil(item, null, bot.game.gameMode === 'creative', name) + if (normalCost === 0) err('Not valid rename, cancelling.') + + if (bot.game.gameMode !== 'creative' && bot.experience.level < normalCost) { + err('Player does not have enough xp to do action, cancelling.') + } + const xpPromise = once(bot, 'experience') + await putSomething(0, item.type, item.metadata, item.count, item.nbt) + sendItemName('') // sent like this by vnailla + if (!bot.supportFeature('useMCItemName')) sendItemName('') + await addCustomName(name) + await bot.putAway(2) + await xpPromise + } + + async function putSomething (destSlot, itemId, metadata, count, nbt) { + const options = { + window: anvil, + itemType: itemId, + metadata, + count, + nbt, + sourceStart: anvil.inventoryStart, + sourceEnd: anvil.inventoryEnd, + destStart: destSlot, + destEnd: destSlot + 1 + } + await bot.transfer(options) + } + + anvil.combine = combine + anvil.rename = rename + + return anvil + } + + bot.openAnvil = openAnvil +} diff --git a/bridge/lib/mineflayer/lib/plugins/bed.js b/bridge/lib/mineflayer/lib/plugins/bed.js new file mode 100644 index 0000000..9b526f6 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/bed.js @@ -0,0 +1,194 @@ +const { Vec3 } = require('vec3') + +module.exports = inject + +const CARDINAL_DIRECTIONS = ['south', 'west', 'north', 'east'] + +function inject (bot) { + bot.isSleeping = false + + const beds = new Set(['white_bed', 'orange_bed', 'magenta_bed', 'light_blue_bed', 'yellow_bed', 'lime_bed', 'pink_bed', 'gray_bed', + 'light_gray_bed', 'cyan_bed', 'purple_bed', 'blue_bed', 'brown_bed', 'green_bed', 'red_bed', 'black_bed', 'bed']) + + function isABed (block) { + return beds.has(block.name) + } + + function parseBedMetadata (bedBlock) { + const metadata = { + part: false, // true: head, false: foot + occupied: 0, + facing: 0, // 0: south, 1: west, 2: north, 3 east + headOffset: new Vec3(0, 0, 1) + } + + if (bot.supportFeature('blockStateId')) { + const state = bedBlock.stateId - bot.registry.blocksByStateId[bedBlock.stateId].minStateId + const bitMetadata = state.toString(2).padStart(4, '0') // FACING (first 2 bits), PART (3rd bit), OCCUPIED (4th bit) + metadata.part = bitMetadata[3] === '0' + metadata.occupied = bitMetadata[2] === '0' + + switch (bitMetadata.slice(0, 2)) { + case '00': + metadata.facing = 2 + metadata.headOffset.set(0, 0, -1) + break + case '10': + metadata.facing = 1 + metadata.headOffset.set(-1, 0, 0) + break + case '11': + metadata.facing = 3 + metadata.headOffset.set(1, 0, 0) + } + } else if (bot.supportFeature('blockMetadata')) { + const bitMetadata = bedBlock.metadata.toString(2).padStart(4, '0') // PART (1st bit), OCCUPIED (2nd bit), FACING (last 2 bits) + metadata.part = bitMetadata[0] === '1' + metadata.occupied = bitMetadata[1] === '1' + + switch (bitMetadata.slice(2, 4)) { + case '01': + metadata.facing = 1 + metadata.headOffset.set(-1, 0, 0) + break + case '10': + metadata.facing = 2 + metadata.headOffset.set(0, 0, -1) + break + case '11': + metadata.facing = 3 + metadata.headOffset.set(1, 0, 0) + } + } + + return metadata + } + + async function wake () { + if (!bot.isSleeping) { + throw new Error('already awake') + } else { + bot._client.write('entity_action', { + entityId: bot.entity.id, + actionId: 2, + jumpBoost: 0 + }) + } + } + + async function sleep (bedBlock) { + const thunderstorm = bot.isRaining && (bot.thunderState > 0) + if (!thunderstorm && !(bot.time.timeOfDay >= 12541 && bot.time.timeOfDay <= 23458)) { + throw new Error("it's not night and it's not a thunderstorm") + } else if (bot.isSleeping) { + throw new Error('already sleeping') + } else if (!isABed(bedBlock)) { + throw new Error('wrong block : not a bed block') + } else { + const botPos = bot.entity.position.floored() + const metadata = parseBedMetadata(bedBlock) + let headPoint = bedBlock.position + + if (metadata.occupied) { + throw new Error('the bed is occupied') + } + + if (!metadata.part) { // Is foot + const upperBlock = bot.blockAt(bedBlock.position.plus(metadata.headOffset)) + + if (isABed(upperBlock)) { + headPoint = upperBlock.position + } else { + const lowerBlock = bot.blockAt(bedBlock.position.plus(metadata.headOffset.scaled(-1))) + + if (isABed(lowerBlock)) { + // If there are 2 foot parts, minecraft only lets you sleep if you click on the lower one + headPoint = bedBlock.position + bedBlock = lowerBlock + } else { + throw new Error("there's only half bed") + } + } + } + + if (!bot.canDigBlock(bedBlock)) { + throw new Error('cant click the bed') + } + + const clickRange = [2, -3, -3, 2] // [south, west, north, east] + const monsterRange = [7, -8, -8, 7] + const oppositeCardinal = (metadata.facing + 2) % CARDINAL_DIRECTIONS.length + + if (clickRange[oppositeCardinal] < 0) { + clickRange[oppositeCardinal]-- + } else { + clickRange[oppositeCardinal]++ + } + + const nwClickCorner = headPoint.offset(clickRange[1], -2, clickRange[2]) // North-West lower corner + const seClickCorner = headPoint.offset(clickRange[3], 2, clickRange[0]) // South-East upper corner + if (botPos.x > seClickCorner.x || botPos.x < nwClickCorner.x || botPos.y > seClickCorner.y || botPos.y < nwClickCorner.y || botPos.z > seClickCorner.z || botPos.z < nwClickCorner.z) { + throw new Error('the bed is too far') + } + + if (bot.game.gameMode !== 'creative' || bot.supportFeature('creativeSleepNearMobs')) { // If in creative mode the bot should be able to sleep even if there are monster nearby (starting in 1.13) + const nwMonsterCorner = headPoint.offset(monsterRange[1], -6, monsterRange[2]) // North-West lower corner + const seMonsterCorner = headPoint.offset(monsterRange[3], 4, monsterRange[0]) // South-East upper corner + + for (const key of Object.keys(bot.entities)) { + const entity = bot.entities[key] + if (entity.kind === 'Hostile mobs') { + const entityPos = entity.position.floored() + if (entityPos.x <= seMonsterCorner.x && entityPos.x >= nwMonsterCorner.x && entityPos.y <= seMonsterCorner.y && entityPos.y >= nwMonsterCorner.y && entityPos.z <= seMonsterCorner.z && entityPos.z >= nwMonsterCorner.z) { + throw new Error('there are monsters nearby') + } + } + } + } + + // We need to register the listener before actually going to sleep to avoid race conditions + const waitingPromise = waitUntilSleep() + bot.activateBlock(bedBlock) + await waitingPromise + } + } + + async function waitUntilSleep () { + return new Promise((resolve, reject) => { + const timeoutForSleep = setTimeout(() => { + reject(new Error('bot is not sleeping')) + }, 3000) + + bot.once('sleep', () => { + clearTimeout(timeoutForSleep) + resolve() + }) + }) + } + + bot._client.on('game_state_change', (packet) => { + if (packet.reason === 0 || packet.reason === 'no_respawn_block_available') { + // occurs when you can't spawn in your bed and your spawn point gets reset + bot.emit('spawnReset') + } + }) + + bot.on('entitySleep', (entity) => { + if (entity === bot.entity) { + bot.isSleeping = true + bot.emit('sleep') + } + }) + + bot.on('entityWake', (entity) => { + if (entity === bot.entity) { + bot.isSleeping = false + bot.emit('wake') + } + }) + + bot.parseBedMetadata = parseBedMetadata + bot.wake = wake + bot.sleep = sleep + bot.isABed = isABed +} diff --git a/bridge/lib/mineflayer/lib/plugins/block_actions.js b/bridge/lib/mineflayer/lib/plugins/block_actions.js new file mode 100644 index 0000000..73e4436 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/block_actions.js @@ -0,0 +1,113 @@ +const { Vec3 } = require('vec3') + +module.exports = inject + +const CARDINALS = { + north: new Vec3(0, 0, -1), + south: new Vec3(0, 0, 1), + west: new Vec3(-1, 0, 0), + east: new Vec3(1, 0, 0) +} + +const FACING_MAP = { + north: { west: 'right', east: 'left' }, + south: { west: 'left', east: 'right' }, + west: { north: 'left', south: 'right' }, + east: { north: 'right', south: 'left' } +} + +function inject (bot) { + const { instruments, blocks } = bot.registry + + // Stores how many players have currently open a container at a certain position + const openCountByPos = {} + + function parseChestMetadata (chestBlock) { + const chestTypes = ['single', 'right', 'left'] + + return bot.supportFeature('doesntHaveChestType') + ? { facing: Object.keys(CARDINALS)[chestBlock.metadata - 2] } + : { + waterlogged: !(chestBlock.metadata & 1), + type: chestTypes[(chestBlock.metadata >> 1) % 3], + facing: Object.keys(CARDINALS)[Math.floor(chestBlock.metadata / 6)] + } + } + + function getChestType (chestBlock) { // Returns 'single', 'right' or 'left' + if (bot.supportFeature('doesntHaveChestType')) { + const facing = parseChestMetadata(chestBlock).facing + + if (!facing) return 'single' + + // We have to check if the adjacent blocks in the perpendicular cardinals are the same type + const perpendicularCardinals = Object.keys(FACING_MAP[facing]) + for (const cardinal of perpendicularCardinals) { + const cardinalOffset = CARDINALS[cardinal] + if (bot.blockAt(chestBlock.position.plus(cardinalOffset))?.type === chestBlock.type) { + return FACING_MAP[cardinal][facing] + } + } + + return 'single' + } else { + return parseChestMetadata(chestBlock).type + } + } + + bot._client.on('block_action', (packet) => { + const pt = new Vec3(packet.location.x, packet.location.y, packet.location.z) + const block = bot.blockAt(pt) + + // Ignore on non-vanilla blocks + if (block === null || !blocks[packet.blockId]) { return } + + const blockName = blocks[packet.blockId].name + + if (blockName === 'noteblock') { // Pre 1.13 + bot.emit('noteHeard', block, instruments[packet.byte1], packet.byte2) + } else if (blockName === 'note_block') { // 1.13 onward + bot.emit('noteHeard', block, instruments[Math.floor(block.metadata / 50)], Math.floor((block.metadata % 50) / 2)) + } else if (blockName === 'sticky_piston' || blockName === 'piston') { + bot.emit('pistonMove', block, packet.byte1, packet.byte2) + } else { + let block2 = null + + if (blockName === 'chest' || blockName === 'trapped_chest') { + const chestType = getChestType(block) + if (chestType === 'right') { + const index = Object.values(FACING_MAP[parseChestMetadata(block).facing]).indexOf('left') + const cardinalBlock2 = Object.keys(FACING_MAP[parseChestMetadata(block).facing])[index] + const block2Position = block.position.plus(CARDINALS[cardinalBlock2]) + block2 = bot.blockAt(block2Position) + } else if (chestType === 'left') return // Omit left part of the chest so 'chestLidMove' doesn't emit twice when it's a double chest + } + + // Emit 'chestLidMove' only if the number of players with the lid open changes + if (openCountByPos[block.position] !== packet.byte2) { + bot.emit('chestLidMove', block, packet.byte2, block2) + + if (packet.byte2 > 0) { + openCountByPos[block.position] = packet.byte2 + } else { + delete openCountByPos[block.position] + } + } + } + }) + + bot._client.on('block_break_animation', (packet) => { + const destroyStage = packet.destroyStage + const pt = new Vec3(packet.location.x, packet.location.y, packet.location.z) + const block = bot.blockAt(pt) + const entity = bot.entities[packet.entityId] + + if (destroyStage < 0 || destroyStage > 9) { + // http://minecraft.wiki/w/Protocol#Block_Break_Progress + // "0-9 to set it, any other value to remove it" + bot.emit('blockBreakProgressEnd', block, entity) + } else { + bot.emit('blockBreakProgressObserved', block, destroyStage, entity) + } + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/blocks.js b/bridge/lib/mineflayer/lib/plugins/blocks.js new file mode 100644 index 0000000..f128f71 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/blocks.js @@ -0,0 +1,608 @@ +const { Vec3 } = require('vec3') +const assert = require('assert') +const Painting = require('../painting') +const { onceWithCleanup } = require('../promise_utils') + +const { OctahedronIterator } = require('prismarine-world').iterators + +module.exports = inject + +const paintingFaceToVec = [ + new Vec3(0, 0, -1), + new Vec3(-1, 0, 0), + new Vec3(0, 0, 1), + new Vec3(1, 0, 0) +] + +const dimensionNames = { + '-1': 'minecraft:nether', + 0: 'minecraft:overworld', + 1: 'minecraft:end' +} + +function inject (bot, { version, storageBuilder, hideErrors }) { + const Block = require('prismarine-block')(bot.registry) + const Chunk = require('prismarine-chunk')(bot.registry) + const World = require('prismarine-world')(bot.registry) + const paintingsByPos = {} + const paintingsById = {} + + function addPainting (painting) { + paintingsById[painting.id] = painting + paintingsByPos[painting.position] = painting + } + + function deletePainting (painting) { + delete paintingsById[painting.id] + delete paintingsByPos[painting.position] + } + + function delColumn (chunkX, chunkZ) { + bot.world.unloadColumn(chunkX, chunkZ) + } + + function addColumn (args) { + if (!args.bitMap && args.groundUp) { + // stop storing the chunk column + delColumn(args.x, args.z) + return + } + let column = bot.world.getColumn(args.x, args.z) + if (!column) { + // Allocates new chunk object while taking world's custom min/max height into account + column = new Chunk({ minY: bot.game.minY, worldHeight: bot.game.height }) + } + + try { + column.load(args.data, args.bitMap, args.skyLightSent, args.groundUp) + if (args.biomes !== undefined) { + column.loadBiomes(args.biomes) + } + if (args.skyLight !== undefined) { + column.loadParsedLight(args.skyLight, args.blockLight, args.skyLightMask, args.blockLightMask, args.emptySkyLightMask, args.emptyBlockLightMask) + } + bot.world.setColumn(args.x, args.z, column) + } catch (e) { + bot.emit('error', e) + } + } + + async function waitForChunksToLoad () { + const dist = 2 + // This makes sure that the bot's real position has been already sent + if (!bot.entity.height) await onceWithCleanup(bot, 'chunkColumnLoad') + const pos = bot.entity.position + const center = new Vec3(pos.x >> 4 << 4, 0, pos.z >> 4 << 4) + // get corner coords of 5x5 chunks around us + const chunkPosToCheck = new Set() + for (let x = -dist; x <= dist; x++) { + for (let y = -dist; y <= dist; y++) { + // ignore any chunks which are already loaded + const pos = center.plus(new Vec3(x, 0, y).scaled(16)) + if (!bot.world.getColumnAt(pos)) chunkPosToCheck.add(pos.toString()) + } + } + + if (chunkPosToCheck.size) { + return new Promise((resolve) => { + function waitForLoadEvents (columnCorner) { + chunkPosToCheck.delete(columnCorner.toString()) + if (chunkPosToCheck.size === 0) { // no chunks left to find + bot.world.off('chunkColumnLoad', waitForLoadEvents) // remove this listener instance + resolve() + } + } + + // begin listening for remaining chunks to load + bot.world.on('chunkColumnLoad', waitForLoadEvents) + }) + } + } + + function getMatchingFunction (matching) { + if (typeof (matching) !== 'function') { + if (!Array.isArray(matching)) { + matching = [matching] + } + return isMatchingType + } + return matching + + function isMatchingType (block) { + return block === null ? false : matching.indexOf(block.type) >= 0 + } + } + + function isBlockInSection (section, matcher) { + if (!section) return false // section is empty, skip it (yay!) + // If the chunk use a palette we can speed up the search by first + // checking the palette which usually contains less than 20 ids + // vs checking the 4096 block of the section. If we don't have a + // match in the palette, we can skip this section. + if (section.palette) { + for (const stateId of section.palette) { + if (matcher(Block.fromStateId(stateId, 0))) { + return true // the block is in the palette + } + } + return false // skip + } + return true // global palette, the block might be in there + } + + function getFullMatchingFunction (matcher, useExtraInfo) { + if (typeof (useExtraInfo) === 'boolean') { + return fullSearchMatcher + } + + return nonFullSearchMatcher + + function nonFullSearchMatcher (point) { + const block = blockAt(point, true) + return matcher(block) && useExtraInfo(block) + } + + function fullSearchMatcher (point) { + return matcher(bot.blockAt(point, useExtraInfo)) + } + } + + bot.findBlocks = (options) => { + const matcher = getMatchingFunction(options.matching) + const point = (options.point || bot.entity.position).floored() + const maxDistance = options.maxDistance || 16 + const count = options.count || 1 + const useExtraInfo = options.useExtraInfo || false + const fullMatcher = getFullMatchingFunction(matcher, useExtraInfo) + const start = new Vec3(Math.floor(point.x / 16), Math.floor(point.y / 16), Math.floor(point.z / 16)) + const it = new OctahedronIterator(start, Math.ceil((maxDistance + 8) / 16)) + // the octahedron iterator can sometime go through the same section again + // we use a set to keep track of visited sections + const visitedSections = new Set() + + let blocks = [] + let startedLayer = 0 + let next = start + while (next) { + const column = bot.world.getColumn(next.x, next.z) + const sectionY = next.y + Math.abs(bot.game.minY >> 4) + const totalSections = bot.game.height >> 4 + if (sectionY >= 0 && sectionY < totalSections && column && !visitedSections.has(next.toString())) { + const section = column.sections[sectionY] + if (useExtraInfo === true || isBlockInSection(section, matcher)) { + const begin = new Vec3(next.x * 16, sectionY * 16 + bot.game.minY, next.z * 16) + const cursor = begin.clone() + const end = cursor.offset(16, 16, 16) + for (cursor.x = begin.x; cursor.x < end.x; cursor.x++) { + for (cursor.y = begin.y; cursor.y < end.y; cursor.y++) { + for (cursor.z = begin.z; cursor.z < end.z; cursor.z++) { + if (fullMatcher(cursor) && cursor.distanceTo(point) <= maxDistance) blocks.push(cursor.clone()) + } + } + } + } + visitedSections.add(next.toString()) + } + // If we started a layer, we have to finish it otherwise we might miss closer blocks + if (startedLayer !== it.apothem && blocks.length >= count) { + break + } + startedLayer = it.apothem + next = it.next() + } + blocks.sort((a, b) => { + return a.distanceTo(point) - b.distanceTo(point) + }) + // We found more blocks than needed, shorten the array to not confuse people + if (blocks.length > count) { + blocks = blocks.slice(0, count) + } + return blocks + } + + function findBlock (options) { + const blocks = bot.findBlocks(options) + if (blocks.length === 0) return null + return bot.blockAt(blocks[0]) + } + + function blockAt (absolutePoint, extraInfos = true) { + const block = bot.world.getBlock(absolutePoint) + // null block means chunk not loaded + if (!block) return null + + if (extraInfos) { + block.painting = paintingsByPos[block.position] + } + + return block + } + + // if passed in block is within line of sight to the bot, returns true + // also works on anything with a position value + function canSeeBlock (block) { + const headPos = bot.entity.position.offset(0, bot.entity.eyeHeight, 0) + const range = headPos.distanceTo(block.position) + const dir = block.position.offset(0.5, 0.5, 0.5).minus(headPos) + const match = (inputBlock, iter) => { + const intersect = iter.intersect(inputBlock.shapes, inputBlock.position) + if (intersect) { return true } + return block.position.equals(inputBlock.position) + } + const blockAtCursor = bot.world.raycast(headPos, dir.normalize(), range, match) + return blockAtCursor && blockAtCursor.position.equals(block.position) + } + + bot._client.on('unload_chunk', (packet) => { + delColumn(packet.chunkX, packet.chunkZ) + }) + + function updateBlockState (point, stateId) { + const oldBlock = blockAt(point) + bot.world.setBlockStateId(point, stateId) + + const newBlock = blockAt(point) + // sometimes minecraft server sends us block updates before it sends + // us the column that the block is in. ignore this. + if (newBlock === null) { + return + } + if (oldBlock.type !== newBlock.type) { + const pos = point.floored() + const painting = paintingsByPos[pos] + if (painting) deletePainting(painting) + } + } + + bot._client.on('update_light', (packet) => { + let column = bot.world.getColumn(packet.chunkX, packet.chunkZ) + if (!column) { + column = new Chunk({ minY: bot.game.minY, worldHeight: bot.game.height }) + bot.world.setColumn(packet.chunkX, packet.chunkZ, column) + } + + if (bot.supportFeature('newLightingDataFormat')) { + column.loadParsedLight(packet.skyLight, packet.blockLight, packet.skyLightMask, packet.blockLightMask, packet.emptySkyLightMask, packet.emptyBlockLightMask) + } else { + column.loadLight(packet.data, packet.skyLightMask, packet.blockLightMask, packet.emptySkyLightMask, packet.emptyBlockLightMask) + } + }) + + // Chunk batches are used by the server to throttle the chunks per tick for players based on their connection speed. + let chunkBatchStartTime = 0 + // The Vanilla client uses nano seconds with its weighted average starting at 2000000 converted to milliseconds that is 2 + let weightedAverage = 2 + // This is used for keeping track of the weight of the old average when updating it. + let oldSampleWeight = 1 + + bot._client.on('chunk_batch_start', (packet) => { + // Get the time the chunk batch is starting. + chunkBatchStartTime = Date.now() + }) + + bot._client.on('chunk_batch_finished', (packet) => { + const milliPerChunk = (Date.now() - chunkBatchStartTime) / packet.batchSize + // Prevents the MilliPerChunk from being hugely different then the average, Vanilla uses 3 as a constant here. + const clampedMilliPerChunk = Math.min(Math.max(milliPerChunk, weightedAverage / 3.0), weightedAverage * 3.0) + weightedAverage = ((weightedAverage * oldSampleWeight) + clampedMilliPerChunk) / (oldSampleWeight + 1) + // 49 is used in Vanilla client to limit it to 50 samples + oldSampleWeight = Math.min(49, oldSampleWeight + 1) + bot._client.write('chunk_batch_received', { + // Vanilla uses 7000000 as a constant here, since we are using milliseconds that is now 7. Not sure why they pick this constant to convert from nano seconds per chunk to chunks per tick. + chunksPerTick: 7 / weightedAverage + }) + }) + bot._client.on('map_chunk', (packet) => { + addColumn({ + x: packet.x, + z: packet.z, + bitMap: packet.bitMap, + heightmaps: packet.heightmaps, + biomes: packet.biomes, + skyLightSent: bot.game.dimension === 'overworld', + groundUp: packet.groundUp, + data: packet.chunkData, + trustEdges: packet.trustEdges, + skyLightMask: packet.skyLightMask, + blockLightMask: packet.blockLightMask, + emptySkyLightMask: packet.emptySkyLightMask, + emptyBlockLightMask: packet.emptyBlockLightMask, + skyLight: packet.skyLight, + blockLight: packet.blockLight + }) + + if (typeof packet.blockEntities !== 'undefined') { + const column = bot.world.getColumn(packet.x, packet.z) + if (!column) { + if (!hideErrors) console.warn('Ignoring block entities as chunk failed to load at', packet.x, packet.z) + return + } + for (const blockEntity of packet.blockEntities) { + if (blockEntity.x !== undefined) { // 1.17+ + column.setBlockEntity(blockEntity, blockEntity.nbtData) + } else { + const pos = new Vec3(blockEntity.value.x.value & 0xf, blockEntity.value.y.value, blockEntity.value.z.value & 0xf) + column.setBlockEntity(pos, blockEntity) + } + } + } + }) + + bot._client.on('map_chunk_bulk', (packet) => { + let offset = 0 + let meta + let i + let size + for (i = 0; i < packet.meta.length; ++i) { + meta = packet.meta[i] + size = (8192 + (packet.skyLightSent ? 2048 : 0)) * + onesInShort(meta.bitMap) + // block ids + 2048 * onesInShort(meta.bitMap) + // (two bytes per block id) + 256 // biomes + addColumn({ + x: meta.x, + z: meta.z, + bitMap: meta.bitMap, + heightmaps: packet.heightmaps, + skyLightSent: packet.skyLightSent, + groundUp: true, + data: packet.data.slice(offset, offset + size) + }) + offset += size + } + + assert.strictEqual(offset, packet.data.length) + }) + + bot._client.on('multi_block_change', (packet) => { + // multi block change + for (let i = 0; i < packet.records.length; ++i) { + const record = packet.records[i] + + let blockX, blockY, blockZ + if (bot.supportFeature('usesMultiblockSingleLong')) { + blockZ = (record >> 4) & 0x0f + blockX = (record >> 8) & 0x0f + blockY = record & 0x0f + } else { + blockZ = record.horizontalPos & 0x0f + blockX = (record.horizontalPos >> 4) & 0x0f + blockY = record.y + } + + let pt + if (bot.supportFeature('usesMultiblock3DChunkCoords')) { + pt = new Vec3(packet.chunkCoordinates.x, packet.chunkCoordinates.y, packet.chunkCoordinates.z) + } else { + pt = new Vec3(packet.chunkX, 0, packet.chunkZ) + } + + pt = pt.scale(16).offset(blockX, blockY, blockZ) + + if (bot.supportFeature('usesMultiblockSingleLong')) { + updateBlockState(pt, record >> 12) + } else { + updateBlockState(pt, record.blockId) + } + } + }) + + bot._client.on('block_change', (packet) => { + const pt = new Vec3(packet.location.x, packet.location.y, packet.location.z) + updateBlockState(pt, packet.type) + }) + + bot._client.on('explosion', (packet) => { + // explosion + const p = new Vec3(packet.x, packet.y, packet.z) + if (packet.affectedBlockOffsets) { + // TODO: server no longer sends in 1.21.3. Is client supposed to compute this or is it sent via normal block updates? + packet.affectedBlockOffsets.forEach((offset) => { + const pt = p.offset(offset.x, offset.y, offset.z) + updateBlockState(pt, 0) + }) + } + }) + + bot._client.on('spawn_entity_painting', (packet) => { + const pos = new Vec3(packet.location.x, packet.location.y, packet.location.z) + const painting = new Painting(packet.entityId, + pos, packet.title, paintingFaceToVec[packet.direction]) + addPainting(painting) + }) + + bot._client.on('entity_destroy', (packet) => { + // destroy entity + packet.entityIds.forEach((id) => { + const painting = paintingsById[id] + if (painting) deletePainting(painting) + }) + }) + + bot._client.on('update_sign', (packet) => { + const pos = new Vec3(packet.location.x & 0xf, packet.location.y, packet.location.z & 0xf) + + // TODO: warn if out of loaded world? + const column = bot.world.getColumn(packet.location.x >> 4, packet.location.z >> 4) + if (!column) { + return + } + + const blockAt = column.getBlock(pos) + + blockAt.signText = [packet.text1, packet.text2, packet.text3, packet.text4].map(text => { + if (text === 'null' || text === '') return '' + return JSON.parse(text) + }) + column.setBlock(pos, blockAt) + }) + + bot._client.on('tile_entity_data', (packet) => { + if (packet.location !== undefined) { + const column = bot.world.getColumn(packet.location.x >> 4, packet.location.z >> 4) + if (!column) return + const pos = new Vec3(packet.location.x & 0xf, packet.location.y, packet.location.z & 0xf) + column.setBlockEntity(pos, packet.nbtData) + } else { + const tag = packet.nbtData + const column = bot.world.getColumn(tag.value.x.value >> 4, tag.value.z.value >> 4) + if (!column) return + const pos = new Vec3(tag.value.x.value & 0xf, tag.value.y.value, tag.value.z.value & 0xf) + column.setBlockEntity(pos, tag) + } + }) + + bot.updateSign = (block, text, back = false) => { + const lines = text.split('\n') + if (lines.length > 4) { + bot.emit('error', new Error('too many lines for sign text')) + return + } + + for (let i = 0; i < lines.length; ++i) { + if (lines[i].length > 45) { + bot.emit('error', new Error('Signs have a maximum of 45 characters per line')) + return + } + } + + let signData + if (bot.supportFeature('sendStringifiedSignText')) { + signData = { + text1: lines[0] ? JSON.stringify(lines[0]) : '""', + text2: lines[1] ? JSON.stringify(lines[1]) : '""', + text3: lines[2] ? JSON.stringify(lines[2]) : '""', + text4: lines[3] ? JSON.stringify(lines[3]) : '""' + } + } else { + signData = { + text1: lines[0] ?? '', + text2: lines[1] ?? '', + text3: lines[2] ?? '', + text4: lines[3] ?? '' + } + } + + bot._client.write('update_sign', { + location: block.position, + isFrontText: !back, + ...signData + }) + } + + // if we get a respawn packet and the dimension is changed, + // unload all chunks from memory. + let dimension + let worldName + function dimensionToFolderName (dimension) { + if (bot.supportFeature('dimensionIsAnInt')) { + return dimensionNames[dimension] + } else if (bot.supportFeature('dimensionIsAString') || bot.supportFeature('dimensionIsAWorld')) { + return worldName + } + } + // only exposed for testing + bot._getDimensionName = () => worldName + + async function switchWorld () { + if (bot.world) { + if (storageBuilder) { + await bot.world.async.waitSaving() + } + + for (const [name, listener] of Object.entries(bot._events)) { + if (name.startsWith('blockUpdate:') && typeof listener === 'function') { + bot.emit(name, null, null) + bot.off(name, listener) + } + } + + for (const [x, z] of Object.keys(bot.world.async.columns).map(key => key.split(',').map(x => parseInt(x, 10)))) { + bot.world.unloadColumn(x, z) + } + + if (storageBuilder) { + bot.world.async.storageProvider = storageBuilder({ version: bot.version, worldName: dimensionToFolderName(dimension) }) + } + } else { + bot.world = new World(null, storageBuilder ? storageBuilder({ version: bot.version, worldName: dimensionToFolderName(dimension) }) : null).sync + startListenerProxy() + } + } + + bot._client.on('login', (packet) => { + if (bot.supportFeature('dimensionIsAnInt')) { + dimension = packet.dimension + worldName = dimensionToFolderName(dimension) + } else if (bot.supportFeature('spawnRespawnWorldDataField')) { // 1.20.5+ + dimension = packet.worldState.dimension + worldName = packet.worldState.name + } else { + dimension = packet.dimension + worldName = /^minecraft:.+/.test(packet.worldName) ? packet.worldName : `minecraft:${packet.worldName}` + } + switchWorld() + }) + + bot._client.on('respawn', (packet) => { + if (bot.supportFeature('dimensionIsAnInt')) { // <=1.15.2 + if (dimension === packet.dimension) return + dimension = packet.dimension + } else if (bot.supportFeature('spawnRespawnWorldDataField')) { // 1.20.5+ + if (dimension === packet.worldState.dimension) return + if (worldName === packet.worldState.name && packet.copyMetadata === true) return // don't unload chunks if in same world and metaData is true + dimension = packet.worldState.dimension + worldName = packet.worldState.name + } else { // >= 1.15.2 + if (dimension === packet.dimension) return + if (worldName === packet.worldName && packet.copyMetadata === true) return // don't unload chunks if in same world and metaData is true + // Metadata is true when switching dimensions however, then the world name is different + dimension = packet.dimension + worldName = packet.worldName + } + switchWorld() + }) + + let listener + let listenerRemove + function startListenerProxy () { + if (listener) { + // custom forwarder for custom events + bot.off('newListener', listener) + bot.off('removeListener', listenerRemove) + } + // standardized forwarding + const forwardedEvents = ['blockUpdate', 'chunkColumnLoad', 'chunkColumnUnload'] + for (const event of forwardedEvents) { + bot.world.on(event, (...args) => bot.emit(event, ...args)) + } + const blockUpdateRegex = /blockUpdate:\(-?\d+, -?\d+, -?\d+\)/ + listener = (event, listener) => { + if (blockUpdateRegex.test(event)) { + bot.world.on(event, listener) + } + } + listenerRemove = (event, listener) => { + if (blockUpdateRegex.test(event)) { + bot.world.off(event, listener) + } + } + bot.on('newListener', listener) + bot.on('removeListener', listenerRemove) + } + + bot.findBlock = findBlock + bot.canSeeBlock = canSeeBlock + bot.blockAt = blockAt + bot._updateBlockState = updateBlockState + bot.waitForChunksToLoad = waitForChunksToLoad +} + +function onesInShort (n) { + n = n & 0xffff + let count = 0 + for (let i = 0; i < 16; ++i) { + count = ((1 << i) & n) ? count + 1 : count + } + return count +} diff --git a/bridge/lib/mineflayer/lib/plugins/book.js b/bridge/lib/mineflayer/lib/plugins/book.js new file mode 100644 index 0000000..9c16169 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/book.js @@ -0,0 +1,101 @@ +const assert = require('assert') +const { once } = require('../promise_utils') + +module.exports = inject + +function inject (bot) { + const Item = require('prismarine-item')(bot.registry) + + let editBook + if (bot.supportFeature('editBookIsPluginChannel')) { + bot._client.registerChannel('MC|BEdit', 'slot') + bot._client.registerChannel('MC|BSign', 'slot') + editBook = (book, pages, title, slot, signing = false) => { + if (signing) bot._client.writeChannel('MC|BSign', Item.toNotch(book)) + else bot._client.writeChannel('MC|BEdit', Item.toNotch(book)) + } + } else if (bot.supportFeature('hasEditBookPacket')) { + if (bot.supportFeature('editBookPacketUsesNbt')) { // 1.13 - 1.17 + editBook = (book, pages, title, slot, signing = false, hand = 0) => { + bot._client.write('edit_book', { + hand: slot, + pages, + title + }) + } + } else { // 1.18+ + editBook = (book, pages, title, slot, signing = false, hand = 0) => { + bot._client.write('edit_book', { + new_book: Item.toNotch(book), + signing, + hand + }) + } + } + } + + async function write (slot, pages, author, title, signing) { + assert.ok(slot >= 0 && slot <= 44, 'slot out of inventory range') + const book = bot.inventory.slots[slot] + assert.ok(book && book.type === bot.registry.itemsByName.writable_book.id, `no book found in slot ${slot}`) + const quickBarSlot = bot.quickBarSlot + const moveToQuickBar = slot < 36 + + if (moveToQuickBar) { + await bot.moveSlotItem(slot, 36) + } + + bot.setQuickBarSlot(moveToQuickBar ? 0 : slot - 36) + + const modifiedBook = await modifyBook(moveToQuickBar ? 36 : slot, pages, author, title, signing) + editBook(modifiedBook, pages, title, moveToQuickBar ? 0 : slot - 36, signing) + await once(bot.inventory, `updateSlot:${moveToQuickBar ? 36 : slot}`) + + bot.setQuickBarSlot(quickBarSlot) + + if (moveToQuickBar) { + await bot.moveSlotItem(36, slot) + } + } + + function modifyBook (slot, pages, author, title, signing) { + const book = Object.assign({}, bot.inventory.slots[slot]) + if (!book.nbt || book.nbt.type !== 'compound') { + book.nbt = { + type: 'compound', + name: '', + value: {} + } + } + if (signing) { + if (bot.supportFeature('clientUpdateBookIdWhenSign')) { + book.type = bot.registry.itemsByName.written_book.id + } + book.nbt.value.author = { + type: 'string', + value: author + } + book.nbt.value.title = { + type: 'string', + value: title + } + } + book.nbt.value.pages = { + type: 'list', + value: { + type: 'string', + value: pages + } + } + bot.inventory.updateSlot(slot, book) + return book + } + + bot.writeBook = async (slot, pages) => { + await write(slot, pages, null, null, false) + } + + bot.signBook = async (slot, pages, author, title) => { + await write(slot, pages, author, title, true) + } +} diff --git a/bridge/lib/mineflayer/lib/plugins/boss_bar.js b/bridge/lib/mineflayer/lib/plugins/boss_bar.js new file mode 100644 index 0000000..1f8021b --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/boss_bar.js @@ -0,0 +1,63 @@ +module.exports = inject + +function inject (bot, { version }) { + const BossBar = require('../bossbar')(bot.registry) + const bars = {} + + function extractTitle (title) { + if (!title) return '' + if (typeof title === 'string') return title + // Return the original object for BossBar to handle + return title + } + + function handleBossBarPacket (packet) { + if (packet.action === 0) { + bars[packet.entityUUID] = new BossBar( + packet.entityUUID, + extractTitle(packet.title), + packet.health, + packet.dividers, + packet.color, + packet.flags + ) + bot.emit('bossBarCreated', bars[packet.entityUUID]) + } else if (packet.action === 1) { + bot.emit('bossBarDeleted', bars[packet.entityUUID]) + delete bars[packet.entityUUID] + } else { + if (!(packet.entityUUID in bars)) { + return + } + if (packet.action === 2 && packet.health !== undefined) { + bars[packet.entityUUID].health = packet.health + } + if (packet.action === 3 && packet.title !== undefined) { + bars[packet.entityUUID].title = extractTitle(packet.title) + } + if (packet.action === 4) { + if (packet.dividers !== undefined) { + bars[packet.entityUUID].dividers = packet.dividers + } + if (packet.color !== undefined) { + bars[packet.entityUUID].color = packet.color + } + } + if (packet.action === 5 && packet.flags !== undefined) { + bars[packet.entityUUID].flags = packet.flags + } + bot.emit('bossBarUpdated', bars[packet.entityUUID]) + } + } + + // Handle all possible packet names + bot._client.on('boss_bar', handleBossBarPacket) + bot._client.on('bossbar', handleBossBarPacket) + bot._client.on('boss_bar_update', handleBossBarPacket) + + Object.defineProperty(bot, 'bossBars', { + get () { + return Object.values(bars) + } + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/breath.js b/bridge/lib/mineflayer/lib/plugins/breath.js new file mode 100644 index 0000000..1371403 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/breath.js @@ -0,0 +1,19 @@ +module.exports = inject + +function inject (bot) { + if (bot.supportFeature('mcDataHasEntityMetadata')) { + // this is handled inside entities.js. We don't yet have entity metadataKeys for all versions but once we do + // we can delete the numerical checks here and in entities.js https://github.com/extremeheat/mineflayer/blob/eb9982aa04973b0086aac68a2847005d77f01a3d/lib/plugins/entities.js#L469 + return + } + bot._client.on('entity_metadata', (packet) => { + if (!bot.entity) return + if (bot.entity.id !== packet.entityId) return + for (const metadata of packet.metadata) { + if (metadata.key === 1) { + bot.oxygenLevel = Math.round(metadata.value / 15) + bot.emit('breath') + } + } + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/chat.js b/bridge/lib/mineflayer/lib/plugins/chat.js new file mode 100644 index 0000000..ef58068 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/chat.js @@ -0,0 +1,221 @@ +const { onceWithCleanup } = require('../promise_utils') + +const USERNAME_REGEX = '(?:\\(.{1,15}\\)|\\[.{1,15}\\]|.){0,5}?(\\w+)' +const LEGACY_VANILLA_CHAT_REGEX = new RegExp(`^${USERNAME_REGEX}\\s?[>:\\-»\\]\\)~]+\\s(.*)$`) + +module.exports = inject + +function inject (bot, options) { + const CHAT_LENGTH_LIMIT = options.chatLengthLimit ?? (bot.supportFeature('lessCharsInChat') ? 100 : 256) + const defaultChatPatterns = options.defaultChatPatterns ?? true + + const ChatMessage = require('prismarine-chat')(bot.registry) + // chat.pattern.type will emit an event for bot.on() of the same type, eg chatType = whisper will trigger bot.on('whisper') + const _patterns = {} + let _length = 0 + // deprecated + bot.chatAddPattern = (patternValue, typeValue) => { + return bot.addChatPattern(typeValue, patternValue, { deprecated: true }) + } + + bot.addChatPatternSet = (name, patterns, opts = {}) => { + if (!patterns.every(p => p instanceof RegExp)) throw new Error('Pattern parameter should be of type RegExp') + const { repeat = true, parse = false } = opts + _patterns[_length++] = { + name, + patterns, + position: 0, + matches: [], + messages: [], + repeat, + parse + } + return _length + } + + bot.addChatPattern = (name, pattern, opts = {}) => { + if (!(pattern instanceof RegExp)) throw new Error('Pattern parameter should be of type RegExp') + const { repeat = true, deprecated = false, parse = false } = opts + _patterns[_length] = { + name, + patterns: [pattern], + position: 0, + matches: [], + messages: [], + deprecated, + repeat, + parse + } + return _length++ // increment length after we give it back to the user + } + + bot.removeChatPattern = name => { + if (typeof name === 'number') { + _patterns[name] = undefined + } else { + const matchingPatterns = Object.entries(_patterns).filter(pattern => pattern[1]?.name === name) + matchingPatterns.forEach(([indexString]) => { + _patterns[+indexString] = undefined + }) + } + } + + function findMatchingPatterns (msg) { + const found = [] + for (const [indexString, pattern] of Object.entries(_patterns)) { + if (!pattern) continue + const { position, patterns } = pattern + if (patterns[position].test(msg)) { + found.push(+indexString) + } + } + return found + } + + bot.on('messagestr', (msg, _, originalMsg) => { + const foundPatterns = findMatchingPatterns(msg) + + for (const ix of foundPatterns) { + _patterns[ix].matches.push(msg) + _patterns[ix].messages.push(originalMsg) + _patterns[ix].position++ + + if (_patterns[ix].deprecated) { + const [, ...matches] = _patterns[ix].matches[0].match(_patterns[ix].patterns[0]) + bot.emit(_patterns[ix].name, ...matches, _patterns[ix].messages[0].translate, ..._patterns[ix].messages) + _patterns[ix].messages = [] // clear out old messages + } else { // regular parsing + if (_patterns[ix].patterns.length > _patterns[ix].matches.length) return // we have all the matches, so we can emit the done event + if (_patterns[ix].parse) { + const matches = _patterns[ix].patterns.map((pattern, i) => { + const [, ...matches] = _patterns[ix].matches[i].match(pattern) // delete full message match + return matches + }) + bot.emit(`chat:${_patterns[ix].name}`, matches) + } else { + bot.emit(`chat:${_patterns[ix].name}`, _patterns[ix].matches) + } + // these are possibly null-ish if the user deletes them as soon as the event for the match is emitted + } + if (_patterns[ix]?.repeat) { + _patterns[ix].position = 0 + _patterns[ix].matches = [] + } else { + _patterns[ix] = undefined + } + } + }) + + addDefaultPatterns() + + bot._client.on('playerChat', (data) => { + const message = data.formattedMessage + const verified = data.verified + let msg + if (bot.supportFeature('clientsideChatFormatting')) { + const parameters = { + sender: data.senderName ? JSON.parse(data.senderName) : undefined, + target: data.targetName ? JSON.parse(data.targetName) : undefined, + content: message ? JSON.parse(message) : { text: data.plainMessage } + } + const registryIndex = data.type.chatType != null ? data.type.chatType : data.type + msg = ChatMessage.fromNetwork(registryIndex, parameters) + + if (data.unsignedContent) { + msg.unsigned = ChatMessage.fromNetwork(registryIndex, { sender: parameters.sender, target: parameters.target, content: JSON.parse(data.unsignedContent) }) + } + } else { + msg = ChatMessage.fromNotch(message) + } + bot.emit('message', msg, 'chat', data.sender, verified) + bot.emit('messagestr', msg.toString(), 'chat', msg, data.sender, verified) + }) + + bot._client.on('systemChat', (data) => { + const msg = ChatMessage.fromNotch(data.formattedMessage) + const chatPositions = { + 1: 'system', + 2: 'game_info' + } + bot.emit('message', msg, chatPositions[data.positionId], null) + bot.emit('messagestr', msg.toString(), chatPositions[data.positionId], msg, null) + if (data.positionId === 2) bot.emit('actionBar', msg, null) + }) + + function chatWithHeader (header, message) { + if (typeof message === 'number') message = message.toString() + if (typeof message !== 'string') { + throw new Error('Chat message type must be a string or number: ' + typeof message) + } + + if (!header && message.startsWith('/')) { + // Do not try and split a command without a header + bot._client.chat(message) + return + } + + const lengthLimit = CHAT_LENGTH_LIMIT - header.length + message.split('\n').forEach((subMessage) => { + if (!subMessage) return + let i + let smallMsg + for (i = 0; i < subMessage.length; i += lengthLimit) { + smallMsg = header + subMessage.substring(i, i + lengthLimit) + bot._client.chat(smallMsg) + } + }) + } + + async function tabComplete (text, assumeCommand = false, sendBlockInSight = true, timeout = 5000) { + let position + + if (sendBlockInSight) { + const block = bot.blockAtCursor() + + if (block) { + position = block.position + } + } + + bot._client.write('tab_complete', { + text, + assumeCommand, + lookedAtBlock: position + }) + + const [packet] = await onceWithCleanup(bot._client, 'tab_complete', { timeout }) + return packet.matches + } + + bot.whisper = (username, message) => { + chatWithHeader(`/tell ${username} `, message) + } + bot.chat = (message) => { + chatWithHeader('', message) + } + + bot.tabComplete = tabComplete + + function addDefaultPatterns () { + // 1.19 changes the chat format to move prefix from message contents to a separate field. + // TODO: new chat lister to handle this + if (!defaultChatPatterns) return + bot.addChatPattern('whisper', new RegExp(`^${USERNAME_REGEX} whispers(?: to you)?:? (.*)$`), { deprecated: true }) + bot.addChatPattern('whisper', new RegExp(`^\\[${USERNAME_REGEX} -> \\w+\\s?\\] (.*)$`), { deprecated: true }) + bot.addChatPattern('chat', LEGACY_VANILLA_CHAT_REGEX, { deprecated: true }) + } + + function awaitMessage (...args) { + return new Promise((resolve, reject) => { + const resolveMessages = args.flatMap(x => x) + function messageListener (msg) { + if (resolveMessages.some(x => x instanceof RegExp ? x.test(msg) : msg === x)) { + resolve(msg) + bot.off('messagestr', messageListener) + } + } + bot.on('messagestr', messageListener) + }) + } + bot.awaitMessage = awaitMessage +} diff --git a/bridge/lib/mineflayer/lib/plugins/chest.js b/bridge/lib/mineflayer/lib/plugins/chest.js new file mode 100644 index 0000000..82e85ff --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/chest.js @@ -0,0 +1,33 @@ +const { Vec3 } = require('vec3') + +module.exports = inject + +function inject (bot) { + const allowedWindowTypes = ['minecraft:generic', 'minecraft:chest', 'minecraft:dispenser', 'minecraft:ender_chest', 'minecraft:shulker_box', 'minecraft:hopper', 'minecraft:container', 'minecraft:dropper', 'minecraft:trapped_chest', 'minecraft:barrel', 'minecraft:white_shulker_box', 'minecraft:orange_shulker_box', 'minecraft:magenta_shulker_box', 'minecraft:light_blue_shulker_box', 'minecraft:yellow_shulker_box', 'minecraft:lime_shulker_box', 'minecraft:pink_shulker_box', 'minecraft:gray_shulker_box', 'minecraft:light_gray_shulker_box', 'minecraft:cyan_shulker_box', 'minecraft:purple_shulker_box', 'minecraft:blue_shulker_box', 'minecraft:brown_shulker_box', 'minecraft:green_shulker_box', 'minecraft:red_shulker_box', 'minecraft:black_shulker_box'] + function matchWindowType (window) { + for (const type of allowedWindowTypes) { + if (window.type.startsWith(type)) return true + } + return false + } + + async function openContainer (containerToOpen, direction, cursorPos) { + direction = direction ?? new Vec3(0, 1, 0) + cursorPos = cursorPos ?? new Vec3(0.5, 0.5, 0.5) + let chest + if (containerToOpen.constructor.name === 'Block' && allowedWindowTypes.map(name => name.replace('minecraft:', '')).includes(containerToOpen.name)) { + chest = await bot.openBlock(containerToOpen, direction, cursorPos) + } else if (containerToOpen.constructor.name === 'Entity') { + chest = await bot.openEntity(containerToOpen) + } else { + throw new Error('containerToOpen is neither a block nor an entity') + } + + if (!matchWindowType(chest)) { throw new Error('Non-container window used as a container: ' + JSON.stringify(chest)) } + return chest + } + + bot.openContainer = openContainer + bot.openChest = openContainer + bot.openDispenser = openContainer +} diff --git a/bridge/lib/mineflayer/lib/plugins/command_block.js b/bridge/lib/mineflayer/lib/plugins/command_block.js new file mode 100644 index 0000000..5bb5441 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/command_block.js @@ -0,0 +1,128 @@ +const assert = require('assert') +const { ProtoDef } = require('protodef') + +module.exports = inject + +function inject (bot) { + function setCommandBlock (pos, command, options = {}) { + assert.strictEqual(bot.player.gamemode, 1, new Error('The bot has to be in creative mode to open the command block window')) + assert.notStrictEqual(pos, null) + assert.notStrictEqual(command, null) + assert.strictEqual(bot.blockAt(pos).name.includes('command_block'), true, new Error("The block isn't a command block")) + + // Default values when a command block is placed in vanilla minecraft + options.trackOutput = options.trackOutput ?? false + options.conditional = options.conditional ?? false + options.alwaysActive = options.alwaysActive ?? false + options.mode = options.mode ?? 2 // Possible values: 0: SEQUENCE, 1: AUTO and 2: REDSTONE + + let flags = 0 + flags |= +options.trackOutput << 0 // 0x01 + flags |= +options.conditional << 1 // 0x02 + flags |= +options.alwaysActive << 2 // 0x04 + + if (bot.supportFeature('usesAdvCmd') || bot.supportFeature('usesAdvCdm')) { + const pluginChannelName = bot.supportFeature('usesAdvCdm') ? 'MC|AdvCdm' : 'MC|AdvCmd' + + const proto = new ProtoDef() + + proto.addType('string', [ + 'pstring', + { + countType: 'varint' + }]) + + proto.addType(pluginChannelName, [ + 'container', + [ + { + name: 'mode', + type: 'i8' + }, + { + name: 'x', + type: [ + 'switch', + { + compareTo: 'mode', + fields: { + 0: 'i32' + }, + default: 'void' + } + ] + }, + { + name: 'y', + type: [ + 'switch', + { + compareTo: 'mode', + fields: { + 0: 'i32' + }, + default: 'void' + } + ] + }, + { + name: 'z', + type: [ + 'switch', + { + compareTo: 'mode', + fields: { + 0: 'i32' + }, + default: 'void' + } + ] + }, + { + name: 'eid', + type: [ + 'switch', + { + compareTo: 'mode', + fields: { + 1: 'i32' + }, + default: 'void' + } + ] + }, + { + name: 'command', + type: 'string' + }, + { + name: 'trackOutput', + type: 'bool' + } + ] + ]) + + const buffer = proto.createPacketBuffer(pluginChannelName, { + mode: 0, + x: pos.x, + y: pos.y, + z: pos.z, + command, + trackOutput: options.trackOutput + }) + bot._client.write('custom_payload', { + channel: pluginChannelName, + data: buffer + }) + } else { + bot._client.write('update_command_block', { + location: pos, + command, + mode: options.mode, + flags + }) + } + } + + bot.setCommandBlock = setCommandBlock +} diff --git a/bridge/lib/mineflayer/lib/plugins/craft.js b/bridge/lib/mineflayer/lib/plugins/craft.js new file mode 100644 index 0000000..a5fe563 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/craft.js @@ -0,0 +1,243 @@ +const assert = require('assert') +const { once } = require('../promise_utils') + +module.exports = inject + +function inject (bot) { + const Item = require('prismarine-item')(bot.registry) + const Recipe = require('prismarine-recipe')(bot.registry).Recipe + let windowCraftingTable + + async function craft (recipe, count, craftingTable) { + assert.ok(recipe) + count = parseInt(count ?? 1, 10) + if (recipe.requiresTable && !craftingTable) { + throw new Error('Recipe requires craftingTable, but one was not supplied: ' + JSON.stringify(recipe)) + } + + try { + for (let i = 0; i < count; i++) { + await craftOnce(recipe, craftingTable) + } + + if (windowCraftingTable) { + bot.closeWindow(windowCraftingTable) + windowCraftingTable = undefined + } + } catch (err) { + if (windowCraftingTable) { + bot.closeWindow(windowCraftingTable) + windowCraftingTable = undefined + } + throw new Error(err) + } + } + + async function craftOnce (recipe, craftingTable) { + if (craftingTable) { + if (!windowCraftingTable) { + bot.activateBlock(craftingTable) + const [window] = await once(bot, 'windowOpen') + windowCraftingTable = window + } + if (!windowCraftingTable.type.startsWith('minecraft:crafting')) { + throw new Error('crafting: non craftingTable used as craftingTable: ' + windowCraftingTable.type) + } + await startClicking(windowCraftingTable, 3, 3) + } else { + await startClicking(bot.inventory, 2, 2) + } + + async function startClicking (window, w, h) { + const extraSlots = unusedRecipeSlots() + let ingredientIndex = 0 + let originalSourceSlot = null + let it + if (recipe.inShape) { + it = { + x: 0, + y: 0, + row: recipe.inShape[0] + } + await clickShape() + } else { + await nextIngredientsClick() + } + + function incrementShapeIterator () { + it.x += 1 + if (it.x >= it.row.length) { + it.y += 1 + if (it.y >= recipe.inShape.length) return null + it.x = 0 + it.row = recipe.inShape[it.y] + } + return it + } + + async function nextShapeClick () { + if (incrementShapeIterator()) { + await clickShape() + } else if (!recipe.ingredients) { + await putMaterialsAway() + } else { + await nextIngredientsClick() + } + } + + async function clickShape () { + const destSlot = slot(it.x, it.y) + const ingredient = it.row[it.x] + if (ingredient.id === -1) return nextShapeClick() + if (!window.selectedItem || window.selectedItem.type !== ingredient.id || + (ingredient.metadata != null && + window.selectedItem.metadata !== ingredient.metadata)) { + // we are not holding the item we need. click it. + const sourceItem = window.findInventoryItem(ingredient.id, ingredient.metadata) + if (!sourceItem) throw new Error('missing ingredient') + if (originalSourceSlot == null) originalSourceSlot = sourceItem.slot + await bot.clickWindow(sourceItem.slot, 0, 0) + } + await bot.clickWindow(destSlot, 1, 0) + await nextShapeClick() + } + + async function nextIngredientsClick () { + const ingredient = recipe.ingredients[ingredientIndex] + const destSlot = extraSlots.pop() + if (!window.selectedItem || window.selectedItem.type !== ingredient.id || + (ingredient.metadata != null && + window.selectedItem.metadata !== ingredient.metadata)) { + // we are not holding the item we need. click it. + const sourceItem = window.findInventoryItem(ingredient.id, ingredient.metadata) + if (!sourceItem) throw new Error('missing ingredient') + if (originalSourceSlot == null) originalSourceSlot = sourceItem.slot + await bot.clickWindow(sourceItem.slot, 0, 0) + } + await bot.clickWindow(destSlot, 1, 0) + if (++ingredientIndex < recipe.ingredients.length) { + await nextIngredientsClick() + } else { + await putMaterialsAway() + } + } + + async function putMaterialsAway () { + const start = window.inventoryStart + const end = window.inventoryEnd + await bot.putSelectedItemRange(start, end, window, originalSourceSlot) + await grabResult() + } + + async function grabResult () { + assert.strictEqual(window.selectedItem, null) + // Causes a double-emit on 1.12+ --nickelpro + // put the recipe result in the output + const item = new Item(recipe.result.id, recipe.result.count, recipe.result.metadata) + window.updateSlot(0, item) + await bot.putAway(0) + await updateOutShape() + } + + async function updateOutShape () { + if (!recipe.outShape) { + for (let i = 1; i <= w * h; i++) { + window.updateSlot(i, null) + } + return + } + const slotsToClick = [] + for (let y = 0; y < recipe.outShape.length; ++y) { + const row = recipe.outShape[y] + for (let x = 0; x < row.length; ++x) { + const _slot = slot(x, y) + let item = null + if (row[x].id !== -1) { + item = new Item(row[x].id, row[x].count, row[x].metadata || null) + slotsToClick.push(_slot) + } + window.updateSlot(_slot, item) + } + } + for (const _slot of slotsToClick) { + await bot.putAway(_slot) + } + } + + function slot (x, y) { + return 1 + x + w * y + } + + function unusedRecipeSlots () { + const result = [] + let x + let y + let row + if (recipe.inShape) { + for (y = 0; y < recipe.inShape.length; ++y) { + row = recipe.inShape[y] + for (x = 0; x < row.length; ++x) { + if (row[x].id === -1) result.push(slot(x, y)) + } + for (; x < w; ++x) { + result.push(slot(x, y)) + } + } + for (; y < h; ++y) { + for (x = 0; x < w; ++x) { + result.push(slot(x, y)) + } + } + } else { + for (y = 0; y < h; ++y) { + for (x = 0; x < w; ++x) { + result.push(slot(x, y)) + } + } + } + return result + } + } + } + + function recipesFor (itemType, metadata, minResultCount, craftingTable) { + minResultCount = minResultCount ?? 1 + const results = [] + Recipe.find(itemType, metadata).forEach((recipe) => { + if (requirementsMetForRecipe(recipe, minResultCount, craftingTable)) { + results.push(recipe) + } + }) + return results + } + + function recipesAll (itemType, metadata, craftingTable) { + const results = [] + Recipe.find(itemType, metadata).forEach((recipe) => { + if (!recipe.requiresTable || craftingTable) { + results.push(recipe) + } + }) + return results + } + + function requirementsMetForRecipe (recipe, minResultCount, craftingTable) { + if (recipe.requiresTable && !craftingTable) return false + + // how many times we have to perform the craft to achieve minResultCount + const craftCount = Math.ceil(minResultCount / recipe.result.count) + + // false if not enough inventory to make all the ones that we want + for (let i = 0; i < recipe.delta.length; ++i) { + const d = recipe.delta[i] + if (bot.inventory.count(d.id, d.metadata) + d.count * craftCount < 0) return false + } + + // otherwise true + return true + } + + bot.craft = craft + bot.recipesFor = recipesFor + bot.recipesAll = recipesAll +} diff --git a/bridge/lib/mineflayer/lib/plugins/creative.js b/bridge/lib/mineflayer/lib/plugins/creative.js new file mode 100644 index 0000000..cd66a72 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/creative.js @@ -0,0 +1,112 @@ +const assert = require('assert') +const { Vec3 } = require('vec3') +const { sleep, onceWithCleanup } = require('../promise_utils') +const { once } = require('../promise_utils') + +module.exports = inject + +function inject (bot) { + const Item = require('prismarine-item')(bot.registry) + + // these features only work when you are in creative mode. + bot.creative = { + setInventorySlot, + flyTo, + startFlying, + stopFlying, + clearSlot: slotNum => setInventorySlot(slotNum, null), + clearInventory + } + + const creativeSlotsUpdates = [] + + // WARN: This method should not be called twice on the same slot before first promise succeeds + async function setInventorySlot (slot, item, waitTimeout = 400) { + assert(slot >= 0 && slot <= 44) + + if (Item.equal(bot.inventory.slots[slot], item, true)) return + if (creativeSlotsUpdates[slot]) { + throw new Error(`Setting slot ${slot} cancelled due to calling bot.creative.setInventorySlot(${slot}, ...) again`) + } + creativeSlotsUpdates[slot] = true + bot._client.write('set_creative_slot', { + slot, + item: Item.toNotch(item) + }) + + if (bot.supportFeature('noAckOnCreateSetSlotPacket')) { + // No ack + bot._setSlot(slot, item) + if (waitTimeout === 0) return // no wait + // allow some time to see if server rejects + return new Promise((resolve, reject) => { + function updateSlot (oldItem, newItem) { + if (newItem.itemId !== item.itemId) { + creativeSlotsUpdates[slot] = false + reject(Error('Server rejected')) + } + } + bot.inventory.once(`updateSlot:${slot}`, updateSlot) + setTimeout(() => { + bot.inventory.off(`updateSlot:${slot}`, updateSlot) + creativeSlotsUpdates[slot] = false + resolve() + }, waitTimeout) + }) + } + + await onceWithCleanup(bot.inventory, `updateSlot:${slot}`, { + timeout: 5000, + checkCondition: (oldItem, newItem) => item === null ? newItem === null : newItem?.name === item.name && newItem?.count === item.count && newItem?.metadata === item.metadata + }) + creativeSlotsUpdates[slot] = false + } + + async function clearInventory () { + return Promise.all(bot.inventory.slots.filter(item => item).map(item => setInventorySlot(item.slot, null))) + } + + let normalGravity = null + const flyingSpeedPerUpdate = 0.5 + + // straight line, so make sure there's a clear path. + async function flyTo (destination) { + // TODO: consider sending 0x13 + startFlying() + + let vector = destination.minus(bot.entity.position) + let magnitude = vecMagnitude(vector) + + while (magnitude > flyingSpeedPerUpdate) { + bot.physics.gravity = 0 + bot.entity.velocity = new Vec3(0, 0, 0) + + // small steps + const normalizedVector = vector.scaled(1 / magnitude) + bot.entity.position.add(normalizedVector.scaled(flyingSpeedPerUpdate)) + + await sleep(50) + + vector = destination.minus(bot.entity.position) + magnitude = vecMagnitude(vector) + } + + // last step + bot.entity.position = destination + await once(bot, 'move', /* no timeout */ 0) + } + + function startFlying () { + if (normalGravity == null) normalGravity = bot.physics.gravity + bot.physics.gravity = 0 + } + + function stopFlying () { + bot.physics.gravity = normalGravity + } +} + +// this should be in the vector library +function vecMagnitude (vec) { + return Math.sqrt(vec.x * vec.x + vec.y * vec.y + vec.z * vec.z) +} diff --git a/bridge/lib/mineflayer/lib/plugins/digging.js b/bridge/lib/mineflayer/lib/plugins/digging.js new file mode 100644 index 0000000..496cf63 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/digging.js @@ -0,0 +1,264 @@ +const { performance } = require('perf_hooks') +const { createDoneTask, createTask } = require('../promise_utils') +const BlockFaces = require('prismarine-world').iterators.BlockFace +const { Vec3 } = require('vec3') + +module.exports = inject + +function inject (bot) { + let swingInterval = null + let waitTimeout = null + + let diggingTask = createDoneTask() + + bot.targetDigBlock = null + bot.targetDigFace = null + bot.lastDigTime = null + + async function dig (block, forceLook, digFace) { + if (block === null || block === undefined) { + throw new Error('dig was called with an undefined or null block') + } + + if (!digFace || typeof digFace === 'function') { + digFace = 'auto' + } + + const waitTime = bot.digTime(block) + if (waitTime === Infinity) { + throw new Error(`dig time for ${block?.name ?? block} is Infinity`) + } + + bot.targetDigFace = 1 // Default (top) + + if (forceLook !== 'ignore') { + if (digFace?.x || digFace?.y || digFace?.z) { + // Determine the block face the bot should mine + if (digFace.x) { + bot.targetDigFace = digFace.x > 0 ? BlockFaces.EAST : BlockFaces.WEST + } else if (digFace.y) { + bot.targetDigFace = digFace.y > 0 ? BlockFaces.TOP : BlockFaces.BOTTOM + } else if (digFace.z) { + bot.targetDigFace = digFace.z > 0 ? BlockFaces.SOUTH : BlockFaces.NORTH + } + await bot.lookAt( + block.position.offset(0.5, 0.5, 0.5).offset(digFace.x * 0.5, digFace.y * 0.5, digFace.z * 0.5), + forceLook + ) + } else if (digFace === 'raycast') { + // Check faces that could be seen from the current position. If the delta is smaller then 0.5 that means the + // bot can most likely not see the face as the block is 1 block thick + // this could be false for blocks that have a smaller bounding box than 1x1x1 + const dx = bot.entity.position.x - (block.position.x + 0.5) + const dy = bot.entity.position.y + bot.entity.eyeHeight - (block.position.y + 0.5) + const dz = bot.entity.position.z - (block.position.z + 0.5) + // Check y first then x and z + const visibleFaces = { + y: Math.sign(Math.abs(dy) > 0.5 ? dy : 0), + x: Math.sign(Math.abs(dx) > 0.5 ? dx : 0), + z: Math.sign(Math.abs(dz) > 0.5 ? dz : 0) + } + const validFaces = [] + const closerBlocks = [] + for (const i in visibleFaces) { + if (!visibleFaces[i]) continue // skip as this face is not visible + // target position on the target block face. -> 0.5 + (current face) * 0.5 + const targetPos = block.position.offset( + 0.5 + (i === 'x' ? visibleFaces[i] * 0.5 : 0), + 0.5 + (i === 'y' ? visibleFaces[i] * 0.5 : 0), + 0.5 + (i === 'z' ? visibleFaces[i] * 0.5 : 0) + ) + const startPos = bot.entity.position.offset(0, bot.entity.eyeHeight, 0) + const rayBlock = bot.world.raycast(startPos, targetPos.clone().subtract(startPos).normalize(), 5) + if (rayBlock) { + if (startPos.distanceTo(rayBlock.intersect) < startPos.distanceTo(targetPos)) { + // Block is closer then the raycasted block + closerBlocks.push(rayBlock) + // continue since if distance is ever less, then we did not intersect the block we wanted, + // meaning that the position of the intersected block is not what we want. + continue + } + const rayPos = rayBlock.position + if ( + rayPos.x === block.position.x && + rayPos.y === block.position.y && + rayPos.z === block.position.z + ) { + validFaces.push({ + face: rayBlock.face, + targetPos: rayBlock.intersect + }) + } + } + } + + if (validFaces.length > 0) { + // Chose closest valid face + let closest + let distSqrt = 999 + for (const i in validFaces) { + const tPos = validFaces[i].targetPos + const cDist = new Vec3(tPos.x, tPos.y, tPos.z).distanceSquared( + bot.entity.position.offset(0, bot.entity.eyeHeight, 0) + ) + if (distSqrt > cDist) { + closest = validFaces[i] + distSqrt = cDist + } + } + await bot.lookAt(closest.targetPos, forceLook) + bot.targetDigFace = closest.face + } else if (closerBlocks.length === 0 && block.shapes.length === 0) { + // no other blocks were detected and the block has no shapes. + // The block in question is replaceable (like tall grass) so we can just dig it + // TODO: do AABB + ray intercept check to this position for digFace. + await bot.lookAt(block.position.offset(0.5, 0.5, 0.5), forceLook) + } else { + // Block is obstructed return error? + throw new Error('Block not in view') + } + } else { + await bot.lookAt(block.position.offset(0.5, 0.5, 0.5), forceLook) + } + } + + // In vanilla the client will cancel digging the current block once the other block is at the crosshair. + // Todo: don't wait until lookAt is at middle of the block, but at the edge of it. + if (bot.targetDigBlock) bot.stopDigging() + + diggingTask = createTask() + bot._client.write('block_dig', { + status: 0, // start digging + location: block.position, + face: bot.targetDigFace // default face is 1 (top) + }) + waitTimeout = setTimeout(finishDigging, waitTime) + bot.targetDigBlock = block + bot.swingArm() + + swingInterval = setInterval(() => { + bot.swingArm() + }, 350) + + function finishDigging () { + clearInterval(swingInterval) + clearTimeout(waitTimeout) + swingInterval = null + waitTimeout = null + if (bot.targetDigBlock) { + bot._client.write('block_dig', { + status: 2, // finish digging + location: bot.targetDigBlock.position, + face: bot.targetDigFace // always the same as the start face + }) + } + bot.targetDigBlock = null + bot.targetDigFace = null + bot.lastDigTime = performance.now() + bot._updateBlockState(block.position, 0) + } + + const eventName = `blockUpdate:${block.position}` + bot.on(eventName, onBlockUpdate) + + const currentBlock = block + bot.stopDigging = () => { + if (!bot.targetDigBlock) return + + // Replicate the odd vanilla cancellation face value. + // When the cancellation is because of a new dig request on another block it's the same as the new dig start face. In all other cases it's 0. + const stoppedBecauseOfNewDigRequest = !currentBlock.position.equals(bot.targetDigBlock.position) + const cancellationDiggingFace = !stoppedBecauseOfNewDigRequest ? bot.targetDigFace : 0 + + bot.removeListener(eventName, onBlockUpdate) + clearInterval(swingInterval) + clearTimeout(waitTimeout) + swingInterval = null + waitTimeout = null + bot._client.write('block_dig', { + status: 1, // cancel digging + location: bot.targetDigBlock.position, + face: cancellationDiggingFace + }) + const block = bot.targetDigBlock + bot.targetDigBlock = null + bot.targetDigFace = null + bot.lastDigTime = performance.now() + bot.emit('diggingAborted', block) + bot.stopDigging = noop + diggingTask.cancel(new Error('Digging aborted')) + } + + function onBlockUpdate (oldBlock, newBlock) { + // vanilla server never actually interrupt digging, but some server send block update when you start digging + // so ignore block update if not air + // All block update listeners receive (null, null) when the world is unloaded. So newBlock can be null. + if (newBlock?.type !== 0) return + bot.removeListener(eventName, onBlockUpdate) + clearInterval(swingInterval) + clearTimeout(waitTimeout) + swingInterval = null + waitTimeout = null + bot.targetDigBlock = null + bot.targetDigFace = null + bot.lastDigTime = performance.now() + bot.emit('diggingCompleted', newBlock) + diggingTask.finish() + } + + await diggingTask.promise + } + + bot.on('death', () => { + bot.removeAllListeners('diggingAborted') + bot.removeAllListeners('diggingCompleted') + bot.stopDigging() + }) + + function canDigBlock (block) { + return ( + block && + block.diggable && + block.position.offset(0.5, 0.5, 0.5).distanceTo(bot.entity.position.offset(0, 1.65, 0)) <= 5.1 + ) + } + + function digTime (block) { + let type = null + let enchantments = [] + + // Retrieve currently held item ID and active enchantments from heldItem + const currentlyHeldItem = bot.heldItem + if (currentlyHeldItem) { + type = currentlyHeldItem.type + enchantments = currentlyHeldItem.enchants + } + + // Append helmet enchantments (because Aqua Affinity actually affects dig speed) + const headEquipmentSlot = bot.getEquipmentDestSlot('head') + const headEquippedItem = bot.inventory.slots[headEquipmentSlot] + if (headEquippedItem) { + const helmetEnchantments = headEquippedItem.enchants + enchantments = enchantments.concat(helmetEnchantments) + } + + const creative = bot.game.gameMode === 'creative' + return block.digTime( + type, + creative, + bot.entity.isInWater, + !bot.entity.onGround, + enchantments, + bot.entity.effects + ) + } + + bot.dig = dig + bot.stopDigging = noop + bot.canDigBlock = canDigBlock + bot.digTime = digTime +} + +function noop (err) { + if (err) throw err +} diff --git a/bridge/lib/mineflayer/lib/plugins/enchantment_table.js b/bridge/lib/mineflayer/lib/plugins/enchantment_table.js new file mode 100644 index 0000000..988bed4 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/enchantment_table.js @@ -0,0 +1,103 @@ +const assert = require('assert') +const { once } = require('../promise_utils') + +module.exports = inject + +function inject (bot) { + async function openEnchantmentTable (enchantmentTableBlock) { + assert.strictEqual(enchantmentTableBlock.name, 'enchanting_table') + let ready = false + const enchantmentTable = await bot.openBlock(enchantmentTableBlock) + if (!enchantmentTable.type.startsWith('minecraft:enchant')) { + throw new Error('Expected minecraft:enchant when opening table but got ' + enchantmentTable.type) + } + + resetEnchantmentOptions() + + enchantmentTable.enchant = enchant + enchantmentTable.takeTargetItem = takeTargetItem + enchantmentTable.putTargetItem = putTargetItem + enchantmentTable.putLapis = putLapis + enchantmentTable.targetItem = function () { return this.slots[0] } + + bot._client.on('craft_progress_bar', onUpdateWindowProperty) + enchantmentTable.once('close', () => { + bot._client.removeListener('craft_progress_bar', onUpdateWindowProperty) + }) + + return enchantmentTable + + function onUpdateWindowProperty (packet) { + if (packet.windowId !== enchantmentTable.id) return + assert.ok(packet.property >= 0) + + const slots = enchantmentTable.enchantments + + if (packet.property < 3) { + const slot = slots[packet.property] + slot.level = packet.value + } else if (packet.property === 3) { + enchantmentTable.xpseed = packet.value + } else if (packet.property < 7) { + const slot = slots[packet.property - 4] + slot.expected.enchant = packet.value + } else if (packet.property < 10) { + const slot = slots[packet.property - 7] + slot.expected.level = packet.value + } + + if (slots[0].level >= 0 && slots[1].level >= 0 && slots[2].level >= 0) { + if (!ready) { + ready = true + enchantmentTable.emit('ready') + } + } else { + ready = false + } + } + + function resetEnchantmentOptions () { + enchantmentTable.xpseed = -1 + enchantmentTable.enchantments = [] + for (let slot = 0; slot < 3; slot++) { + enchantmentTable.enchantments.push({ + level: -1, + expected: { + enchant: -1, + level: -1 + } + }) + } + ready = false + } + + async function enchant (choice) { + if (!ready) await once(enchantmentTable, 'ready') + choice = parseInt(choice, 10) // allow string argument + assert.notStrictEqual(enchantmentTable.enchantments[choice].level, -1) + bot._client.write('enchant_item', { + windowId: enchantmentTable.id, + enchantment: choice + }) + const [, newItem] = await once(enchantmentTable, 'updateSlot:0') + return newItem + } + + async function takeTargetItem () { + const item = enchantmentTable.targetItem() + assert.ok(item) + await bot.putAway(item.slot) + return item + } + + async function putTargetItem (item) { + await bot.moveSlotItem(item.slot, 0) + } + + async function putLapis (item) { + await bot.moveSlotItem(item.slot, 1) + } + } + + bot.openEnchantmentTable = openEnchantmentTable +} diff --git a/bridge/lib/mineflayer/lib/plugins/entities.js b/bridge/lib/mineflayer/lib/plugins/entities.js new file mode 100644 index 0000000..57069cc --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/entities.js @@ -0,0 +1,956 @@ +const { Vec3 } = require('vec3') +const conv = require('../conversions') +// These values are only accurate for versions 1.14 and above (crouch hitbox changes) +// Todo: hitbox sizes for sleeping, swimming/crawling, and flying with elytra +const PLAYER_HEIGHT = 1.8 +const CROUCH_HEIGHT = 1.5 +const PLAYER_WIDTH = 0.6 +const PLAYER_EYEHEIGHT = 1.62 +const CROUCH_EYEHEIGHT = 1.27 + +module.exports = inject + +const animationEvents = { + 0: 'entitySwingArm', + 1: 'entityHurt', + 2: 'entityWake', + 3: 'entityEat', + 4: 'entityCriticalEffect', + 5: 'entityMagicCriticalEffect' +} + +const entityStatusEvents = { + 2: 'entityHurt', + 3: 'entityDead', + 6: 'entityTaming', + 7: 'entityTamed', + 8: 'entityShakingOffWater', + 10: 'entityEatingGrass', + 55: 'entityHandSwap' +} + +function inject (bot) { + const { mobs } = bot.registry + const Entity = require('prismarine-entity')(bot.version) + const Item = require('prismarine-item')(bot.version) + const ChatMessage = require('prismarine-chat')(bot.registry) + + // ONLY 1.17 has this destroy_entity packet which is the same thing as entity_destroy packet except the entity is singular + // 1.17.1 reverted this change so this is just a simpler fix + bot._client.on('destroy_entity', (packet) => { + bot._client.emit('entity_destroy', { entityIds: [packet.entityId] }) + }) + + bot.findPlayer = bot.findPlayers = (filter) => { + const filterFn = (entity) => { + if (entity.type !== 'player') return false + if (filter === null) return true + if (typeof filter === 'object' && filter instanceof RegExp) { + return entity.username.search(filter) !== -1 + } else if (typeof filter === 'function') { + return filter(entity) + } else if (typeof filter === 'string') { + return entity.username.toLowerCase() === filter.toLowerCase() + } + return false + } + const resultSet = Object.values(bot.entities) + .filter(filterFn) + + if (typeof filter === 'string') { + switch (resultSet.length) { + case 0: + return null + case 1: + return resultSet[0] + default: + return resultSet + } + } + return resultSet + } + + bot.players = {} + bot.uuidToUsername = {} + bot.entities = {} + + bot._playerFromUUID = (uuid) => Object.values(bot.players).find(player => player.uuid === uuid) + + bot.nearestEntity = (match = (entity) => { return true }) => { + let best = null + let bestDistance = Number.MAX_VALUE + + for (const entity of Object.values(bot.entities)) { + if (entity === bot.entity || !match(entity)) { + continue + } + + const dist = bot.entity.position.distanceSquared(entity.position) + if (dist < bestDistance) { + best = entity + bestDistance = dist + } + } + + return best + } + + // Reset list of players and entities on login + bot._client.on('login', (packet) => { + bot.players = {} + bot.uuidToUsername = {} + bot.entities = {} + // login + bot.entity = fetchEntity(packet.entityId) + bot.username = bot._client.username + bot.entity.username = bot._client.username + bot.entity.type = 'player' + bot.entity.name = 'player' + bot.entity.height = PLAYER_HEIGHT + bot.entity.width = PLAYER_WIDTH + bot.entity.eyeHeight = PLAYER_EYEHEIGHT + }) + + bot._client.on('entity_equipment', (packet) => { + // entity equipment + const entity = fetchEntity(packet.entityId) + if (packet.equipments !== undefined) { + packet.equipments.forEach(equipment => entity.setEquipment(equipment.slot, equipment.item ? Item.fromNotch(equipment.item) : null)) + } else { + entity.setEquipment(packet.slot, packet.item ? Item.fromNotch(packet.item) : null) + } + bot.emit('entityEquip', entity) + }) + + bot._client.on('bed', (packet) => { + // use bed + const entity = fetchEntity(packet.entityId) + entity.position.set(packet.location.x, packet.location.y, packet.location.z) + bot.emit('entitySleep', entity) + }) + + bot._client.on('animation', (packet) => { + // animation + const entity = fetchEntity(packet.entityId) + const eventName = animationEvents[packet.animation] + if (eventName) bot.emit(eventName, entity) + }) + + bot.on('entityCrouch', (entity) => { + entity.eyeHeight = CROUCH_EYEHEIGHT + entity.height = CROUCH_HEIGHT + }) + + bot.on('entityUncrouch', (entity) => { + entity.eyeHeight = PLAYER_EYEHEIGHT + entity.height = PLAYER_HEIGHT + }) + + bot._client.on('collect', (packet) => { + // collect item + const collector = fetchEntity(packet.collectorEntityId) + const collected = fetchEntity(packet.collectedEntityId) + bot.emit('playerCollect', collector, collected) + }) + + // What is internalId? + const entityDataByInternalId = Object.fromEntries(bot.registry.entitiesArray.map((e) => [e.internalId, e])) + + function setEntityData (entity, type, entityData) { + entityData ??= entityDataByInternalId[type] + if (entityData) { + entity.type = entityData.type || 'object' + entity.displayName = entityData.displayName + entity.entityType = entityData.id + entity.name = entityData.name + entity.kind = entityData.category + entity.height = entityData.height + entity.width = entityData.width + } else { + // unknown entity + entity.type = 'other' + entity.entityType = type + entity.displayName = 'unknown' + entity.name = 'unknown' + entity.kind = 'unknown' + } + } + + function updateEntityPos (entity, pos) { + if (bot.supportFeature('fixedPointPosition')) { + entity.position.set(pos.x / 32, pos.y / 32, pos.z / 32) + } else if (bot.supportFeature('doublePosition')) { + entity.position.set(pos.x, pos.y, pos.z) + } + entity.yaw = conv.fromNotchianYawByte(pos.yaw) + entity.pitch = conv.fromNotchianPitchByte(pos.pitch) + } + + function addNewPlayer (entityId, uuid, pos) { + const entity = fetchEntity(entityId) + entity.type = 'player' + entity.name = 'player' + entity.username = bot.uuidToUsername[uuid] + entity.uuid = uuid + updateEntityPos(entity, pos) + entity.eyeHeight = PLAYER_EYEHEIGHT + entity.height = PLAYER_HEIGHT + entity.width = PLAYER_WIDTH + if (bot.players[entity.username] !== undefined && !bot.players[entity.username].entity) { + bot.players[entity.username].entity = entity + } + return entity + } + + function addNewNonPlayer (entityId, uuid, entityType, pos) { + const entity = fetchEntity(entityId) + const entityData = bot.registry.entities[entityType] + setEntityData(entity, entityType, entityData) + updateEntityPos(entity, pos) + entity.uuid = uuid + return entity + } + + bot._client.on('named_entity_spawn', (packet) => { + // in case player_info packet was not sent before named_entity_spawn : ignore named_entity_spawn (see #213) + if (packet.playerUUID in bot.uuidToUsername) { + // spawn named entity + const entity = addNewPlayer(packet.entityId, packet.playerUUID, packet, packet.metadata) + entity.dataBlobs = packet.data // this field doesn't appear to be listed on any version + entity.metadata = parseMetadata(packet.metadata, entity.metadata) // 1.8 + bot.emit('entitySpawn', entity) + } + }) + + // spawn object/vehicle on versions < 1.19, on versions > 1.19 handles all non-player entities + // on versions >= 1.20.2, this also handles player entities + bot._client.on('spawn_entity', (packet) => { + const entityData = entityDataByInternalId[packet.type] + const entity = entityData?.type === 'player' + ? addNewPlayer(packet.entityId, packet.objectUUID, packet) + : addNewNonPlayer(packet.entityId, packet.objectUUID, packet.type, packet) + bot.emit('entitySpawn', entity) + }) + + // spawn_entity_experience_orb packet was removed in 1.21.5+ + // XP orbs are now handled through the general spawn_entity packet + bot._client.on('spawn_entity_experience_orb', (packet) => { + const entity = fetchEntity(packet.entityId) + entity.type = 'orb' + entity.name = 'experience_orb' + entity.width = 0.5 + entity.height = 0.5 + + if (bot.supportFeature('fixedPointPosition')) { + entity.position.set(packet.x / 32, packet.y / 32, packet.z / 32) + } else if (bot.supportFeature('doublePosition')) { + entity.position.set(packet.x, packet.y, packet.z) + } + + entity.count = packet.count + bot.emit('entitySpawn', entity) + }) + + // This packet is removed since 1.19 and merged into spawn_entity + bot._client.on('spawn_entity_living', (packet) => { + // spawn mob + const entity = fetchEntity(packet.entityId) + entity.type = 'mob' + entity.uuid = packet.entityUUID + const entityData = mobs[packet.type] + + setEntityData(entity, packet.type, entityData) + + if (bot.supportFeature('fixedPointPosition')) { + entity.position.set(packet.x / 32, packet.y / 32, packet.z / 32) + } else if (bot.supportFeature('doublePosition')) { + entity.position.set(packet.x, packet.y, packet.z) + } + + entity.yaw = conv.fromNotchianYawByte(packet.yaw) + entity.pitch = conv.fromNotchianPitchByte(packet.pitch) + entity.headPitch = conv.fromNotchianPitchByte(packet.headPitch) + + const notchVel = new Vec3(packet.velocityX, packet.velocityY, packet.velocityZ) + entity.velocity.update(conv.fromNotchVelocity(notchVel)) + entity.metadata = parseMetadata(packet.metadata, entity.metadata) + + bot.emit('entitySpawn', entity) + }) + + bot._client.on('entity_velocity', (packet) => { + // entity velocity + const entity = fetchEntity(packet.entityId) + const notchVel = new Vec3(packet.velocityX, packet.velocityY, packet.velocityZ) + entity.velocity.update(conv.fromNotchVelocity(notchVel)) + }) + + bot._client.on('entity_destroy', (packet) => { + // destroy entity + packet.entityIds.forEach((id) => { + const entity = fetchEntity(id) + bot.emit('entityGone', entity) + entity.isValid = false + if (entity.username && bot.players[entity.username]) { + bot.players[entity.username].entity = null + } + delete bot.entities[id] + }) + }) + + bot._client.on('rel_entity_move', (packet) => { + // entity relative move + const entity = fetchEntity(packet.entityId) + if (bot.supportFeature('fixedPointDelta')) { + entity.position.translate(packet.dX / 32, packet.dY / 32, packet.dZ / 32) + } else if (bot.supportFeature('fixedPointDelta128')) { + entity.position.translate(packet.dX / (128 * 32), packet.dY / (128 * 32), packet.dZ / (128 * 32)) + } + bot.emit('entityMoved', entity) + }) + + bot._client.on('entity_look', (packet) => { + // entity look + const entity = fetchEntity(packet.entityId) + entity.yaw = conv.fromNotchianYawByte(packet.yaw) + entity.pitch = conv.fromNotchianPitchByte(packet.pitch) + bot.emit('entityMoved', entity) + }) + + bot._client.on('entity_move_look', (packet) => { + // entity look and relative move + const entity = fetchEntity(packet.entityId) + if (bot.supportFeature('fixedPointDelta')) { + entity.position.translate(packet.dX / 32, packet.dY / 32, packet.dZ / 32) + } else if (bot.supportFeature('fixedPointDelta128')) { + entity.position.translate(packet.dX / (128 * 32), packet.dY / (128 * 32), packet.dZ / (128 * 32)) + } + entity.yaw = conv.fromNotchianYawByte(packet.yaw) + entity.pitch = conv.fromNotchianPitchByte(packet.pitch) + bot.emit('entityMoved', entity) + }) + + bot._client.on('entity_teleport', (packet) => { + // entity teleport + const entity = fetchEntity(packet.entityId) + if (bot.supportFeature('fixedPointPosition')) { + entity.position.set(packet.x / 32, packet.y / 32, packet.z / 32) + } + if (bot.supportFeature('doublePosition')) { + entity.position.set(packet.x, packet.y, packet.z) + } + entity.yaw = conv.fromNotchianYawByte(packet.yaw) + entity.pitch = conv.fromNotchianPitchByte(packet.pitch) + bot.emit('entityMoved', entity) + }) + + // 1.21.3 - merges the packets above + bot._client.on('sync_entity_position', (packet) => { + const entity = fetchEntity(packet.entityId) + entity.position.set(packet.x, packet.y, packet.z) + entity.velocity.set(packet.dx, packet.dy, packet.dz) + entity.yaw = packet.yaw + entity.pitch = packet.pitch + bot.emit('entityMoved', entity) + }) + + bot._client.on('entity_head_rotation', (packet) => { + // entity head look + const entity = fetchEntity(packet.entityId) + entity.headYaw = conv.fromNotchianYawByte(packet.headYaw) + bot.emit('entityMoved', entity) + }) + + bot._client.on('entity_status', (packet) => { + // entity status + const entity = fetchEntity(packet.entityId) + const eventName = entityStatusEvents[packet.entityStatus] + + if (eventName === 'entityHandSwap' && entity.equipment) { + [entity.equipment[0], entity.equipment[1]] = [entity.equipment[1], entity.equipment[0]] + entity.heldItem = entity.equipment[0] // Update held item like prismarine-entity does upon equipment updates + } + + if (eventName) bot.emit(eventName, entity) + }) + + bot._client.on('damage_event', (packet) => { // 1.20+ + const entity = bot.entities[packet.entityId] + const source = bot.entities[packet.sourceCauseId - 1] // damage_event : SourceCauseId : The ID + 1 of the entity responsible for the damage, if present. If not present, the value is 0 + bot.emit('entityHurt', entity, source) + }) + + bot._client.on('attach_entity', (packet) => { + // attach entity + const entity = fetchEntity(packet.entityId) + if (packet.vehicleId === -1) { + const vehicle = entity.vehicle + delete entity.vehicle + bot.emit('entityDetach', entity, vehicle) + } else { + entity.vehicle = fetchEntity(packet.vehicleId) + bot.emit('entityAttach', entity, entity.vehicle) + } + }) + + bot.fireworkRocketDuration = 0 + function setElytraFlyingState (entity, elytraFlying) { + let startedFlying = false + if (elytraFlying) { + startedFlying = !entity.elytraFlying + entity.elytraFlying = true + } else if (entity.elytraFlying) { + entity.elytraFlying = false + } + if (bot.fireworkRocketDuration !== 0 && entity.id === bot.entity?.id && !elytraFlying) { + bot.fireworkRocketDuration = 0 + knownFireworks.clear() + } + + if (startedFlying) { + bot.emit('entityElytraFlew', entity) + } + } + + const knownFireworks = new Set() + function handleBotUsedFireworkRocket (fireworkEntityId, fireworkInfo) { + if (knownFireworks.has(fireworkEntityId)) return + knownFireworks.add(fireworkEntityId) + let flightDur = fireworkInfo?.nbtData?.value?.Fireworks?.value?.Flight.value ?? 1 + if (typeof flightDur !== 'number') { flightDur = 1 } + const baseDuration = 10 * (flightDur + 1) + const randomDuration = Math.floor(Math.random() * 6) + Math.floor(Math.random() * 7) + bot.fireworkRocketDuration = baseDuration + randomDuration + + bot.emit('usedFirework', fireworkEntityId) + } + + let fireworkEntityName + if (bot.supportFeature('fireworkNamePlural')) { + fireworkEntityName = 'fireworks_rocket' + } else if (bot.supportFeature('fireworkNameSingular')) { + fireworkEntityName = 'firework_rocket' + } + + let fireworkMetadataIdx + let fireworkMetadataIsOpt + if (bot.supportFeature('fireworkMetadataVarInt7')) { + fireworkMetadataIdx = 7 + fireworkMetadataIsOpt = false + } else if (bot.supportFeature('fireworkMetadataOptVarInt8')) { + fireworkMetadataIdx = 8 + fireworkMetadataIsOpt = true + } else if (bot.supportFeature('fireworkMetadataOptVarInt9')) { + fireworkMetadataIdx = 9 + fireworkMetadataIsOpt = true + } + const hasFireworkSupport = fireworkEntityName !== undefined && fireworkMetadataIdx !== undefined && fireworkMetadataIsOpt !== undefined + + bot._client.on('entity_metadata', (packet) => { + // entity metadata + const entity = fetchEntity(packet.entityId) + const metadata = parseMetadata(packet.metadata, entity.metadata) + entity.metadata = metadata + bot.emit('entityUpdate', entity) + + if (bot.supportFeature('mcDataHasEntityMetadata')) { + const metadataKeys = bot.registry.entitiesByName[entity.name]?.metadataKeys + const metas = metadataKeys ? Object.fromEntries(packet.metadata.map(e => [metadataKeys[e.key], e.value])) : {} + if (packet.metadata.some(m => m.type === 'item_stack')) { + bot.emit('itemDrop', entity) + } + if (metas.sleeping_pos || metas.pose === 2) { + bot.emit('entitySleep', entity) + } + + if (hasFireworkSupport && fireworkEntityName === entity.name && metas.attached_to_target !== undefined) { + // fireworkMetadataOptVarInt9 and later is implied by + // mcDataHasEntityMetadata, so no need to check metadata index and type + // (eg fireworkMetadataOptVarInt8) + if (metas.attached_to_target !== 0) { + const entityId = metas.attached_to_target - 1 + if (entityId === bot.entity?.id) { + handleBotUsedFireworkRocket(entity.id, metas.fireworks_item) + } + } + } + + if (metas.shared_flags != null) { + if (bot.supportFeature('hasElytraFlying')) { + const elytraFlying = metas.shared_flags & 0x80 + setElytraFlyingState(entity, Boolean(elytraFlying)) + } + + if (metas.shared_flags & 2) { + entity.crouching = true + bot.emit('entityCrouch', entity) + } else if (entity.crouching) { // prevent the initial entity_metadata packet from firing off an uncrouch event + entity.crouching = false + bot.emit('entityUncrouch', entity) + } + } + + // Breathing (formerly in breath.js) + if (metas.air_supply != null) { + bot.oxygenLevel = Math.round(metas.air_supply / 15) + bot.emit('breath') + } + } else { + const typeSlot = (bot.supportFeature('itemsAreAlsoBlocks') ? 5 : 6) + (bot.supportFeature('entityMetadataHasLong') ? 1 : 0) + const slot = packet.metadata.find(e => e.type === typeSlot) + if (entity.name && (entity.name.toLowerCase() === 'item' || entity.name === 'item_stack') && slot) { + bot.emit('itemDrop', entity) + } + + const typePose = bot.supportFeature('entityMetadataHasLong') ? 19 : 18 + const pose = packet.metadata.find(e => e.type === typePose) + if (pose && pose.value === 2) { + bot.emit('entitySleep', entity) + } + + if (hasFireworkSupport && fireworkEntityName === entity.name) { + const attachedToTarget = packet.metadata.find(e => e.key === fireworkMetadataIdx) + if (attachedToTarget !== undefined) { + let entityId + if (fireworkMetadataIsOpt) { + if (attachedToTarget.value !== 0) { + entityId = attachedToTarget.value - 1 + } // else, not attached to an entity + } else { + entityId = attachedToTarget.value + } + if (entityId !== undefined && entityId === bot.entity?.id) { + const fireworksItem = packet.metadata.find(e => e.key === (fireworkMetadataIdx - 1)) + handleBotUsedFireworkRocket(entity.id, fireworksItem?.value) + } + } + } + + const bitField = packet.metadata.find(p => p.key === 0) + if (bitField !== undefined) { + if (bot.supportFeature('hasElytraFlying')) { + const elytraFlying = bitField.value & 0x80 + setElytraFlyingState(entity, Boolean(elytraFlying)) + } + + if ((bitField.value & 2) !== 0) { + entity.crouching = true + bot.emit('entityCrouch', entity) + } else if (entity.crouching) { // prevent the initial entity_metadata packet from firing off an uncrouch event + entity.crouching = false + bot.emit('entityUncrouch', entity) + } + } + } + }) + + bot._client.on('entity_effect', (packet) => { + // entity effect + const entity = fetchEntity(packet.entityId) + const effect = { + id: packet.effectId, + amplifier: packet.amplifier, + duration: packet.duration + } + entity.effects[effect.id] = effect + bot.emit('entityEffect', entity, effect) + }) + + bot._client.on('remove_entity_effect', (packet) => { + // remove entity effect + const entity = fetchEntity(packet.entityId) + let effect = entity.effects[packet.effectId] + if (effect) { + delete entity.effects[effect.id] + } else { + // unknown effect + effect = { + id: packet.effectId, + amplifier: -1, + duration: -1 + } + } + bot.emit('entityEffectEnd', entity, effect) + }) + + const updateAttributes = (packet) => { + const entity = fetchEntity(packet.entityId) + if (!entity.attributes) entity.attributes = {} + for (const prop of packet.properties) { + entity.attributes[prop.key] = { + value: prop.value, + modifiers: prop.modifiers + } + } + bot.emit('entityAttributes', entity) + } + bot._client.on('update_attributes', updateAttributes) // 1.8 + bot._client.on('entity_update_attributes', updateAttributes) // others + + bot._client.on('spawn_entity_weather', (packet) => { + // spawn global entity + const entity = fetchEntity(packet.entityId) + entity.type = 'global' + entity.globalType = 'thunderbolt' + entity.uuid = packet.entityUUID + entity.position.set(packet.x / 32, packet.y / 32, packet.z / 32) + bot.emit('entitySpawn', entity) + }) + + bot.on('spawn', () => { + bot.emit('entitySpawn', bot.entity) + }) + + function handlePlayerInfoBitfield (packet) { + for (const item of packet.data) { + let player = bot._playerFromUUID(item.uuid) + const newPlayer = !player + + if (newPlayer) { + player = { uuid: item.uuid } + } + + if (packet.action.add_player) { + player.username = item.player.name + player.displayName = new ChatMessage({ text: '', extra: [{ text: item.player.name }] }) + player.skinData = extractSkinInformation(item.player.properties) + } + if (packet.action.initialize_chat && item.chatSession) { + player.chatSession = { + publicKey: item.chatSession.publicKey, + sessionUuid: item.chatSession.uuid + } + } + if (packet.action.update_game_mode) { + player.gamemode = item.gamemode + } + if (packet.action.update_listed) { + player.listed = item.listed + } + if (packet.action.update_latency) { + player.ping = item.latency + } + if (packet.action.update_display_name) { + player.displayName = item.displayName ? ChatMessage.fromNotch(item.displayName) : new ChatMessage({ text: '', extra: [{ text: player.username }] }) + } + + if (newPlayer) { + if (!player.username) continue // Should be unreachable if add_player is always sent for new players + bot.players[player.username] = player + bot.uuidToUsername[player.uuid] = player.username + } + + const playerEntity = Object.values(bot.entities).find(e => e.type === 'player' && e.username === player.username) + player.entity = playerEntity + + if (playerEntity === bot.entity) { + bot.player = player + } + + if (newPlayer) { + bot.emit('playerJoined', player) + } else { + bot.emit('playerUpdated', player) + } + } + } + + function handlePlayerInfoLegacy (packet) { + for (const item of packet.data) { + let player = bot._playerFromUUID(item.uuid) + + switch (packet.action) { + case 'add_player': { + const newPlayer = !player + if (newPlayer) { + player = bot.players[item.name] = { + username: item.name, + uuid: item.uuid + } + bot.uuidToUsername[item.uuid] = item.name + } + + player.ping = item.ping + player.gamemode = item.gamemode + player.displayName = item.displayName ? ChatMessage.fromNotch(item.displayName) : new ChatMessage({ text: '', extra: [{ text: item.name }] }) + if (item.properties) { + player.skinData = extractSkinInformation(item.properties) + } + if (item.crypto) { + player.profileKeys = { + publicKey: item.crypto.publicKey, + signature: item.crypto.signature + } + } + + const playerEntity = Object.values(bot.entities).find(e => e.type === 'player' && e.username === item.name) + player.entity = playerEntity + if (playerEntity === bot.entity) { + bot.player = player + } + + if (newPlayer) bot.emit('playerJoined', player) + else bot.emit('playerUpdated', player) + break + } + case 'update_gamemode': { + if (player) { + player.gamemode = item.gamemode + bot.emit('playerUpdated', player) + } + break + } + case 'update_latency': { + if (player) { + player.ping = item.ping + bot.emit('playerUpdated', player) + } + break + } + case 'update_display_name': { + if (player) { + player.displayName = item.displayName ? ChatMessage.fromNotch(item.displayName) : new ChatMessage({ text: '', extra: [{ text: player.username }] }) + bot.emit('playerUpdated', player) + } + break + } + case 'remove_player': { + if (player) { + if (player.entity === bot.entity) continue + player.entity = null + delete bot.players[player.username] + delete bot.uuidToUsername[item.uuid] + bot.emit('playerLeft', player) + } + break + } + } + } + } + + bot._client.on('player_info', bot.supportFeature('playerInfoActionIsBitfield') ? handlePlayerInfoBitfield : handlePlayerInfoLegacy) + + // 1.19.3+ - player(s) leave the game + bot._client.on('player_remove', (packet) => { + for (const uuid of packet.players) { + const player = bot._playerFromUUID(uuid) + if (!player || player.entity === bot.entity) continue + + player.entity = null + delete bot.players[player.username] + delete bot.uuidToUsername[uuid] + bot.emit('playerLeft', player) + } + }) + + // attaching to a vehicle + bot._client.on('attach_entity', (packet) => { + const passenger = fetchEntity(packet.entityId) + const vehicle = packet.vehicleId === -1 ? null : fetchEntity(packet.vehicleId) + + const originalVehicle = passenger.vehicle + if (originalVehicle) { + const index = originalVehicle.passengers.indexOf(passenger) + originalVehicle.passengers.splice(index, 1) + } + passenger.vehicle = vehicle + if (vehicle) { + vehicle.passengers.push(passenger) + } + + if (packet.entityId === bot.entity.id) { + const vehicle = bot.vehicle + if (packet.vehicleId === -1) { + bot.vehicle = null + bot.emit('dismount', vehicle) + } else { + bot.vehicle = bot.entities[packet.vehicleId] + bot.emit('mount') + } + } + }) + + bot._client.on('set_passengers', ({ entityId, passengers }) => { + const passengerEntities = passengers.map((passengerId) => fetchEntity(passengerId)) + const vehicle = entityId === -1 ? null : bot.entities[entityId] + + for (const passengerEntity of passengerEntities) { + const originalVehicle = passengerEntity.vehicle + if (originalVehicle) { + const index = originalVehicle.passengers.indexOf(passengerEntity) + originalVehicle.passengers.splice(index, 1) + } + passengerEntity.vehicle = vehicle + if (vehicle) { + vehicle.passengers.push(passengerEntity) + } + } + + if (passengers.includes(bot.entity.id)) { + const originalVehicle = bot.vehicle + if (entityId === -1) { + bot.vehicle = null + bot.emit('dismount', originalVehicle) + } else { + bot.vehicle = bot.entities[entityId] + bot.emit('mount') + } + } + }) + + // dismounting when the vehicle is gone + bot._client.on('entityGone', (entity) => { + if (bot.vehicle === entity) { + bot.vehicle = null + bot.emit('dismount', (entity)) + } + if (entity.passengers) { + for (const passenger of entity.passengers) { + passenger.vehicle = null + } + } + if (entity.vehicle) { + const index = entity.vehicle.passengers.indexOf(entity) + if (index !== -1) { + entity.vehicle.passengers.splice(index, 1) + } + } + }) + + bot.swingArm = swingArm + bot.attack = attack + bot.mount = mount + bot.dismount = dismount + bot.useOn = useOn + bot.moveVehicle = moveVehicle + + function swingArm (arm = 'right', showHand = true) { + const hand = arm === 'right' ? 0 : 1 + const packet = {} + if (showHand) packet.hand = hand + bot._client.write('arm_animation', packet) + } + + function useOn (target) { + // TODO: check if not crouching will make make this action always use the item + useEntity(target, 0) + } + + function attack (target, swing = true) { + // arm animation comes before the use_entity packet on 1.8 + if (bot.supportFeature('armAnimationBeforeUse')) { + if (swing) { + swingArm() + } + useEntity(target, 1) + } else { + useEntity(target, 1) + if (swing) { + swingArm() + } + } + } + + function mount (target) { + // TODO: check if crouching will make make this action always mount + useEntity(target, 0) + } + + function moveVehicle (left, forward) { + if (bot.supportFeature('newPlayerInputPacket')) { + // docs: + // * left can take -1 or 1 : -1 means right, 1 means left + // * forward can take -1 or 1 : -1 means backward, 1 means forward + bot._client.write('player_input', { + inputs: { + forward: forward > 0, + backward: forward < 0, + left: left > 0, + right: left < 0 + } + }) + } else { + bot._client.write('steer_vehicle', { + sideways: left, + forward, + jump: 0x01 + }) + } + } + + function dismount () { + if (bot.vehicle) { + if (bot.supportFeature('newPlayerInputPacket')) { + bot._client.write('player_input', { + inputs: { + jump: true + } + }) + } else { + bot._client.write('steer_vehicle', { + sideways: 0.0, + forward: 0.0, + jump: 0x02 + }) + } + } else { + bot.emit('error', new Error('dismount: not mounted')) + } + } + + function useEntity (target, leftClick, x, y, z) { + const sneaking = bot.getControlState('sneak') + if (x && y && z) { + bot._client.write('use_entity', { + target: target.id, + mouse: leftClick, + x, + y, + z, + sneaking + }) + } else { + bot._client.write('use_entity', { + target: target.id, + mouse: leftClick, + sneaking + }) + } + } + + function fetchEntity (id) { + return bot.entities[id] || (bot.entities[id] = new Entity(id)) + } +} + +function parseMetadata (metadata, entityMetadata = {}) { + if (metadata !== undefined) { + for (const { key, value } of metadata) { + entityMetadata[key] = value + } + } + + return entityMetadata +} + +function extractSkinInformation (properties) { + if (!properties) { + return undefined + } + + const props = Object.fromEntries(properties.map((e) => [e.name, e])) + if (!props.textures || !props.textures.value) { + return undefined + } + + const skinTexture = JSON.parse(Buffer.from(props.textures.value, 'base64').toString('utf8')) + + const skinTextureUrl = skinTexture?.textures?.SKIN?.url ?? undefined + const skinTextureModel = skinTexture?.textures?.SKIN?.metadata?.model ?? undefined + + if (!skinTextureUrl) { + return undefined + } + + return { url: skinTextureUrl, model: skinTextureModel } +} diff --git a/bridge/lib/mineflayer/lib/plugins/experience.js b/bridge/lib/mineflayer/lib/plugins/experience.js new file mode 100644 index 0000000..1c8334a --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/experience.js @@ -0,0 +1,15 @@ +module.exports = inject + +function inject (bot) { + bot.experience = { + level: null, + points: null, + progress: null + } + bot._client.on('experience', (packet) => { + bot.experience.level = packet.level + bot.experience.points = packet.totalExperience + bot.experience.progress = packet.experienceBar + bot.emit('experience') + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/explosion.js b/bridge/lib/mineflayer/lib/plugins/explosion.js new file mode 100644 index 0000000..15e8706 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/explosion.js @@ -0,0 +1,93 @@ +const { Vec3 } = require('vec3') + +module.exports = inject + +// https://minecraft.wiki/w/Explosion +function calcExposure (playerPos, explosionPos, world) { + const dx = 1 / (0.6 * 2 + 1) + const dy = 1 / (1.8 * 2 + 1) + const dz = 1 / (0.6 * 2 + 1) + + const d3 = (1 - Math.floor(1 / dx) * dx) / 2 + const d4 = (1 - Math.floor(1 / dz) * dz) / 2 + + let sampled = 0 + let exposed = 0 + const pos = new Vec3(0, 0, 0) + for (pos.y = playerPos.y; pos.y <= playerPos.y + 1.8; pos.y += 1.8 * dy) { + for (pos.x = playerPos.x - 0.3 + d3; pos.x <= playerPos.x + 0.3; pos.x += 0.6 * dx) { + for (pos.z = playerPos.z - 0.3 + d4; pos.z <= playerPos.z + 0.3; pos.z += 0.6 * dz) { + const dir = pos.minus(explosionPos) + const range = dir.norm() + if (world.raycast(explosionPos, dir.normalize(), range) === null) { + exposed++ + } + sampled++ + } + } + } + return exposed / sampled +} + +// https://minecraft.wiki/w/Armor#Damage_protection +function getDamageAfterAbsorb (damages, armorValue, toughness) { + const var3 = 2 + toughness / 4 + const var4 = Math.min(Math.max(armorValue - damages / var3, armorValue * 0.2), 20) + return damages * (1 - var4 / 25) +} + +// https://minecraft.wiki/w/Attribute#Operations +function getAttributeValue (prop) { + let X = prop.value + for (const mod of prop.modifiers) { + if (mod.operation !== 0) continue + X += mod.amount + } + let Y = X + for (const mod of prop.modifiers) { + if (mod.operation !== 1) continue + Y += X * mod.amount + } + for (const mod of prop.modifiers) { + if (mod.operation !== 2) continue + Y += Y * mod.amount + } + return Y +} + +function inject (bot) { + const damageMultiplier = 7 // for 1.12+ 8 for 1.8 TODO check when the change occur (likely 1.9) + const armorThoughnessKey = 'generic.armorToughness' // was renamed in 1.16 + + const difficultyValues = { + peaceful: 0, + easy: 1, + normal: 2, + hard: 3 + } + + bot.getExplosionDamages = (targetEntity, sourcePos, power, rawDamages = false) => { + const distance = targetEntity.position.distanceTo(sourcePos) + const radius = 2 * power + if (distance >= radius) return 0 + const exposure = calcExposure(targetEntity.position, sourcePos, bot.world) + const impact = (1 - distance / radius) * exposure + let damages = Math.floor((impact * impact + impact) * damageMultiplier * power + 1) + + // The following modifiers are constant for the input targetEntity and doesnt depend + // on the source position, so if the goal is to compare between positions they can be + // ignored to save computations + if (!rawDamages && targetEntity.attributes['generic.armor']) { + const armor = getAttributeValue(targetEntity.attributes['generic.armor']) + const armorToughness = getAttributeValue(targetEntity.attributes[armorThoughnessKey]) + damages = getDamageAfterAbsorb(damages, armor, armorToughness) + + // TODO: protection enchantment and resistance effects + + if (targetEntity.type === 'player') damages *= difficultyValues[bot.game.difficulty] * 0.5 + } else if (!rawDamages && !targetEntity.attributes['generic.armor']) { + return null + } + return Math.floor(damages) + } +} diff --git a/bridge/lib/mineflayer/lib/plugins/fishing.js b/bridge/lib/mineflayer/lib/plugins/fishing.js new file mode 100644 index 0000000..325be12 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/fishing.js @@ -0,0 +1,61 @@ +const { Vec3 } = require('vec3') +const { createDoneTask, createTask } = require('../promise_utils') + +module.exports = inject + +function inject (bot) { + let bobberId = 90 + // Before 1.14 the bobber entity keep changing name at each version (but the id stays 90) + // 1.14 changes the id, but hopefully we can stick with the name: fishing_bobber + // the alternative would be to rename it in all version of mcData + if (bot.supportFeature('fishingBobberCorrectlyNamed')) { + bobberId = bot.registry.entitiesByName.fishing_bobber.id + } + + let fishingTask = createDoneTask() + let lastBobber = null + + bot._client.on('spawn_entity', (packet) => { + if (packet.type === bobberId && !fishingTask.done && !lastBobber) { + lastBobber = bot.entities[packet.entityId] + } + }) + + bot._client.on('world_particles', (packet) => { + if (!lastBobber || fishingTask.done) return + + const pos = lastBobber.position + + const bobberCondition = bot.registry.supportFeature('updatedParticlesPacket') + ? ((packet.particle.type === 'fishing' || packet.particle.type === 'bubble') && packet.amount === 6 && pos.distanceTo(new Vec3(packet.x, pos.y, packet.z)) <= 1.23) + // This "(particles.fishing ?? particles.bubble).id" condition doesn't make sense (these are both valid types) + : (packet.particleId === (bot.registry.particlesByName.fishing ?? bot.registry.particlesByName.bubble).id && packet.particles === 6 && pos.distanceTo(new Vec3(packet.x, pos.y, packet.z)) <= 1.23) + + if (bobberCondition) { + bot.activateItem() + lastBobber = undefined + fishingTask.finish() + } + }) + bot._client.on('entity_destroy', (packet) => { + if (!lastBobber) return + if (packet.entityIds.some(id => id === lastBobber.id)) { + lastBobber = undefined + fishingTask.cancel(new Error('Fishing cancelled')) + } + }) + + async function fish () { + if (!fishingTask.done) { + fishingTask.cancel(new Error('Fishing cancelled due to calling bot.fish() again')) + } + + fishingTask = createTask() + + bot.activateItem() + + await fishingTask.promise + } + + bot.fish = fish +} diff --git a/bridge/lib/mineflayer/lib/plugins/furnace.js b/bridge/lib/mineflayer/lib/plugins/furnace.js new file mode 100644 index 0000000..43ff153 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/furnace.js @@ -0,0 +1,121 @@ +const assert = require('assert') + +module.exports = inject + +function inject (bot) { + const allowedWindowTypes = ['minecraft:furnace', 'minecraft:blast_furnace', 'minecraft:smoker'] + + function matchWindowType (window) { + for (const type of allowedWindowTypes) { + if (window.type.startsWith(type)) return true + } + return false + } + + async function openFurnace (furnaceBlock) { + const furnace = await bot.openBlock(furnaceBlock) + if (!matchWindowType(furnace)) { + throw new Error('This is not a furnace-like window') + } + + furnace.totalFuel = null + furnace.fuel = null + furnace.fuelSeconds = null + furnace.totalProgress = null + furnace.progress = null + furnace.progressSeconds = null + furnace.takeInput = takeInput + furnace.takeFuel = takeFuel + furnace.takeOutput = takeOutput + furnace.putInput = putInput + furnace.putFuel = putFuel + furnace.inputItem = function () { return this.slots[0] } + furnace.fuelItem = function () { return this.slots[1] } + furnace.outputItem = function () { return this.slots[2] } + + bot._client.on('craft_progress_bar', onUpdateWindowProperty) + furnace.once('close', () => { + bot._client.removeListener('craft_progress_bar', onUpdateWindowProperty) + }) + + return furnace + + function onUpdateWindowProperty (packet) { + if (packet.windowId !== furnace.id) return + + switch (packet.property) { + case 0: // Current fuel + furnace.fuel = 0 + furnace.fuelSeconds = 0 + if (furnace.totalFuel) { + furnace.fuel = packet.value / furnace.totalFuel + furnace.fuelSeconds = furnace.fuel * furnace.totalFuelSeconds + } + break + case 1: // Total fuel + furnace.totalFuel = packet.value + furnace.totalFuelSeconds = ticksToSeconds(furnace.totalFuel) + break + case 2: // Current progress + furnace.progress = 0 + furnace.progressSeconds = 0 + if (furnace.totalProgress) { + furnace.progress = packet.value / furnace.totalProgress + furnace.progressSeconds = furnace.totalProgressSeconds - (furnace.progress * furnace.totalProgressSeconds) + } + break + case 3: // Total progress + furnace.totalProgress = packet.value + furnace.totalProgressSeconds = ticksToSeconds(furnace.totalProgress) + } + + furnace.emit('update') + } + + async function takeSomething (item) { + assert.ok(item) + await bot.putAway(item.slot) + return item + } + + async function takeInput () { + return takeSomething(furnace.inputItem()) + } + + async function takeFuel () { + return takeSomething(furnace.fuelItem()) + } + + async function takeOutput () { + return takeSomething(furnace.outputItem()) + } + + async function putSomething (destSlot, itemType, metadata, count) { + const options = { + window: furnace, + itemType, + metadata, + count, + sourceStart: furnace.inventoryStart, + sourceEnd: furnace.inventoryEnd, + destStart: destSlot, + destEnd: destSlot + 1 + } + await bot.transfer(options) + } + + async function putInput (itemType, metadata, count) { + await putSomething(0, itemType, metadata, count) + } + + async function putFuel (itemType, metadata, count) { + await putSomething(1, itemType, metadata, count) + } + } + + function ticksToSeconds (ticks) { + return ticks * 0.05 + } + + bot.openFurnace = openFurnace +} diff --git a/bridge/lib/mineflayer/lib/plugins/game.js b/bridge/lib/mineflayer/lib/plugins/game.js new file mode 100644 index 0000000..ca6c219 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/game.js @@ -0,0 +1,142 @@ +const nbt = require('prismarine-nbt') +module.exports = inject + +const difficultyNames = ['peaceful', 'easy', 'normal', 'hard'] +const gameModes = ['survival', 'creative', 'adventure', 'spectator'] + +const dimensionNames = { + '-1': 'the_nether', + 0: 'overworld', + 1: 'the_end' +} + +const parseGameMode = gameModeBits => { + if (gameModeBits < 0 || gameModeBits > 0b11) { + return 'survival' + } + return gameModes[(gameModeBits & 0b11)] // lower two bits +} + +function inject (bot, options) { + function getBrandCustomChannelName () { + if (bot.supportFeature('customChannelMCPrefixed')) { + return 'MC|Brand' + } else if (bot.supportFeature('customChannelIdentifier')) { + return 'minecraft:brand' + } + throw new Error('Unsupported brand channel name') + } + + function handleRespawnPacketData (packet) { + bot.game.levelType = packet.levelType ?? (packet.isFlat ? 'flat' : 'default') + bot.game.hardcore = packet.isHardcore ?? Boolean(packet.gameMode & 0b100) + // Either a respawn packet or a login packet. Depending on the packet it can be "gamemode" or "gameMode" + if (bot.supportFeature('spawnRespawnWorldDataField')) { // 1.20.5 + bot.game.gameMode = packet.gamemode + } else { + bot.game.gameMode = parseGameMode(packet.gamemode ?? packet.gameMode) + } + if (bot.supportFeature('segmentedRegistryCodecData')) { // 1.20.5 + if (typeof packet.dimension === 'number') { + bot.game.dimension = bot.registry.dimensionsArray[packet.dimension]?.name?.replace('minecraft:', '') + } else if (typeof packet.dimension === 'string') { // iirc, in 1.21 it's back to a string + bot.game.dimension = packet.dimension.replace('minecraft:', '') + } + } else if (bot.supportFeature('dimensionIsAnInt')) { + bot.game.dimension = dimensionNames[packet.dimension] + } else if (bot.supportFeature('dimensionIsAString')) { + bot.game.dimension = packet.dimension.replace('minecraft:', '') + } else if (bot.supportFeature('dimensionIsAWorld')) { + bot.game.dimension = packet.worldName.replace('minecraft:', '') + } else { + throw new Error('Unsupported dimension type in login packet') + } + + if (packet.dimensionCodec) { + bot.registry.loadDimensionCodec(packet.dimensionCodec) + } + + bot.game.minY = 0 + bot.game.height = 256 + + if (bot.supportFeature('dimensionDataInCodec')) { // 1.19+ + // pre 1.20.5 before we consolidated login and respawn's SpawnInfo structure into one type, + // "dimension" was called "worldType" in login_packet's payload but not respawn. + if (packet.worldType && !bot.game.dimension) { + bot.game.dimension = packet.worldType.replace('minecraft:', '') + } + const dimData = bot.registry.dimensionsByName[bot.game.dimension] + if (dimData) { + bot.game.minY = dimData.minY + bot.game.height = dimData.height + } + } else if (bot.supportFeature('dimensionDataIsAvailable')) { // 1.16.2+ + const dimensionData = nbt.simplify(packet.dimension) + bot.game.minY = dimensionData.min_y + bot.game.height = dimensionData.height + } + + if (packet.difficulty) { + bot.game.difficulty = difficultyNames[packet.difficulty] + } + } + + bot.game = {} + + const brandChannel = getBrandCustomChannelName() + bot._client.registerChannel(brandChannel, ['string', []]) + + // 1.20.2 + bot._client.on('registry_data', (packet) => { + bot.registry.loadDimensionCodec(packet.codec || packet) + }) + + bot._client.on('login', (packet) => { + handleRespawnPacketData(packet.worldState || packet) + + bot.game.maxPlayers = packet.maxPlayers + if (packet.enableRespawnScreen) { + bot.game.enableRespawnScreen = packet.enableRespawnScreen + } + if (packet.viewDistance) { + bot.game.serverViewDistance = packet.viewDistance + } + + bot.emit('login') + bot.emit('game') + + // varint length-prefixed string as data + bot._client.writeChannel(brandChannel, options.brand) + }) + + bot._client.on('respawn', (packet) => { + // in 1.20.5+ protocol we move the shared spawn data into one SpawnInfo type under .worldState + handleRespawnPacketData(packet.worldState || packet) + bot.emit('game') + }) + + bot._client.on('game_state_change', (packet) => { + if ((packet.reason === 4 || packet.reason === 'win_game') && packet.gameMode === 1) { + bot._client.write('client_command', { action: 0 }) + } + if ((packet.reason === 3) || (packet.reason === 'change_game_mode')) { + bot.game.gameMode = parseGameMode(packet.gameMode) + bot.emit('game') + } + }) + + bot._client.on('difficulty', (packet) => { + bot.game.difficulty = difficultyNames[packet.difficulty] + }) + + bot._client.on(brandChannel, (serverBrand) => { + bot.game.serverBrand = serverBrand + }) + + // mimic the vanilla 1.17 client to prevent anticheat kicks + bot._client.on('ping', (data) => { + bot._client.write('pong', { + id: data.id + }) + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/generic_place.js b/bridge/lib/mineflayer/lib/plugins/generic_place.js new file mode 100644 index 0000000..448dfc0 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/generic_place.js @@ -0,0 +1,108 @@ +const assert = require('assert') +module.exports = inject + +function inject (bot) { + const Item = require('prismarine-item')(bot.registry) + /** + * + * @param {import('prismarine-block').Block} referenceBlock + * @param {import('vec3').Vec3} faceVector + * @param {{half?: 'top'|'bottom', delta?: import('vec3').Vec3, forceLook?: boolean | 'ignore', offhand?: boolean, swingArm?: 'right' | 'left', showHand?: boolean}} options + */ + async function _genericPlace (referenceBlock, faceVector, options) { + let handToPlaceWith = 0 + if (options.offhand) { + if (!bot.inventory.slots[45]) { + throw new Error('must be holding an item in the off-hand to place') + } + handToPlaceWith = 1 + } else if (!bot.heldItem) { + throw new Error('must be holding an item to place') + } + + // Look at the center of the face + let dx = 0.5 + faceVector.x * 0.5 + let dy = 0.5 + faceVector.y * 0.5 + let dz = 0.5 + faceVector.z * 0.5 + if (dy === 0.5) { + if (options.half === 'top') dy += 0.25 + else if (options.half === 'bottom') dy -= 0.25 + } + if (options.delta) { + dx = options.delta.x + dy = options.delta.y + dz = options.delta.z + } + if (options.forceLook !== 'ignore') { + await bot.lookAt(referenceBlock.position.offset(dx, dy, dz), options.forceLook) + } + // TODO: tell the server that we are sneaking while doing this + const pos = referenceBlock.position + + if (options.swingArm) { + bot.swingArm(options.swingArm, options.showHand) + } + + if (bot.supportFeature('blockPlaceHasHeldItem')) { + const packet = { + location: pos, + direction: vectorToDirection(faceVector), + heldItem: Item.toNotch(bot.heldItem), + cursorX: Math.floor(dx * 16), + cursorY: Math.floor(dy * 16), + cursorZ: Math.floor(dz * 16) + } + bot._client.write('block_place', packet) + } else if (bot.supportFeature('blockPlaceHasHandAndIntCursor')) { + bot._client.write('block_place', { + location: pos, + direction: vectorToDirection(faceVector), + hand: handToPlaceWith, + cursorX: Math.floor(dx * 16), + cursorY: Math.floor(dy * 16), + cursorZ: Math.floor(dz * 16) + }) + } else if (bot.supportFeature('blockPlaceHasHandAndFloatCursor')) { + bot._client.write('block_place', { + location: pos, + direction: vectorToDirection(faceVector), + hand: handToPlaceWith, + cursorX: dx, + cursorY: dy, + cursorZ: dz + }) + } else if (bot.supportFeature('blockPlaceHasInsideBlock')) { + bot._client.write('block_place', { + location: pos, + direction: vectorToDirection(faceVector), + hand: handToPlaceWith, + cursorX: dx, + cursorY: dy, + cursorZ: dz, + insideBlock: false, + sequence: 0, // 1.19.0 + worldBorderHit: false // 1.21.3 + }) + } + + return pos + } + bot._genericPlace = _genericPlace +} + +function vectorToDirection (v) { + if (v.y < 0) { + return 0 + } else if (v.y > 0) { + return 1 + } else if (v.z < 0) { + return 2 + } else if (v.z > 0) { + return 3 + } else if (v.x < 0) { + return 4 + } else if (v.x > 0) { + return 5 + } + assert.ok(false, `invalid direction vector ${v}`) +} diff --git a/bridge/lib/mineflayer/lib/plugins/health.js b/bridge/lib/mineflayer/lib/plugins/health.js new file mode 100644 index 0000000..ba35b73 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/health.js @@ -0,0 +1,41 @@ +module.exports = inject + +function inject (bot, options) { + bot.isAlive = true + + bot._client.on('respawn', (packet) => { + bot.isAlive = false + bot.emit('respawn') + }) + + bot._client.once('update_health', (packet) => { + if (packet.health > 0) { + bot.emit('spawn') + } + }) + + bot._client.on('update_health', (packet) => { + bot.health = packet.health + bot.food = packet.food + bot.foodSaturation = packet.foodSaturation + bot.emit('health') + if (bot.health <= 0) { + if (bot.isAlive) { + bot.isAlive = false + bot.emit('death') + } + if (!options.respawn) return + bot.respawn() + } else if (bot.health > 0 && !bot.isAlive) { + bot.isAlive = true + bot.emit('spawn') + } + }) + + const respawn = () => { + if (bot.isAlive) return + bot._client.write('client_command', bot.supportFeature('respawnIsPayload') ? { payload: 0 } : { actionId: 0 }) + } + + bot.respawn = respawn +} diff --git a/bridge/lib/mineflayer/lib/plugins/inventory.js b/bridge/lib/mineflayer/lib/plugins/inventory.js new file mode 100644 index 0000000..9c69c5c --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/inventory.js @@ -0,0 +1,786 @@ +const assert = require('assert') +const { Vec3 } = require('vec3') +const { once, sleep, createDoneTask, createTask, withTimeout } = require('../promise_utils') + +module.exports = inject + +// ms to wait before clicking on a tool so the server can send the new +// damage information +const DIG_CLICK_TIMEOUT = 500 +// The number of milliseconds to wait for the server to respond with consume completion. +// This number is larger than the eat time of 1.61 seconds to account for latency and low tps. +// The eat time comes from https://minecraft.wiki/w/Food#Usage +const CONSUME_TIMEOUT = 2500 +// milliseconds to wait for the server to respond to a window click transaction +const WINDOW_TIMEOUT = 5000 + +const ALWAYS_CONSUMABLES = [ + 'potion', + 'milk_bucket', + 'enchanted_golden_apple', + 'golden_apple' +] + +function inject (bot, { hideErrors }) { + const Item = require('prismarine-item')(bot.registry) + const windows = require('prismarine-windows')(bot.registry) + + let eatingTask = createDoneTask() + let sequence = 0 + + let nextActionNumber = 0 // < 1.17 + let stateId = -1 + if (bot.supportFeature('stateIdUsed')) { + const listener = packet => { stateId = packet.stateId } + bot._client.on('window_items', listener) + bot._client.on('set_slot', listener) + } + const windowClickQueue = [] + let windowItems + + // 0-8, null = uninitialized + // which quick bar slot is selected + bot.quickBarSlot = null + bot.inventory = windows.createWindow(0, 'minecraft:inventory', 'Inventory') + bot.currentWindow = null + bot.usingHeldItem = false + + Object.defineProperty(bot, 'heldItem', { + get: function () { + return bot.inventory.slots[bot.QUICK_BAR_START + bot.quickBarSlot] + } + }) + + bot.on('spawn', () => { + Object.defineProperty(bot.entity, 'equipment', { + get: bot.supportFeature('doesntHaveOffHandSlot') + ? function () { + return [bot.heldItem, bot.inventory.slots[8], bot.inventory.slots[7], + bot.inventory.slots[6], bot.inventory.slots[5]] + } + : function () { + return [bot.heldItem, bot.inventory.slots[45], bot.inventory.slots[8], + bot.inventory.slots[7], bot.inventory.slots[6], bot.inventory.slots[5]] + } + }) + }) + + bot._client.on('entity_status', (packet) => { + if (packet.entityId === bot.entity.id && packet.entityStatus === 9 && !eatingTask.done) { + eatingTask.finish() + } + bot.usingHeldItem = false + }) + + let previousHeldItem = null + bot.on('heldItemChanged', (heldItem) => { + // we only disable the item if the item type or count changes + if ( + heldItem?.type === previousHeldItem?.type && heldItem?.count === previousHeldItem?.count + ) { + previousHeldItem = heldItem + return + } + if (!eatingTask.done) { + eatingTask.finish() + } + bot.usingHeldItem = false + }) + + bot._client.on('set_cooldown', (packet) => { + if (bot.heldItem && bot.heldItem.type !== packet.itemID) return + if (!eatingTask.done) { + eatingTask.finish() + } + bot.usingHeldItem = false + }) + + async function consume () { + if (!eatingTask.done) { + eatingTask.cancel(new Error('Consuming cancelled due to calling bot.consume() again')) + } + + if (bot.game.gameMode !== 'creative' && !ALWAYS_CONSUMABLES.includes(bot.heldItem.name) && bot.food === 20) { + throw new Error('Food is full') + } + + eatingTask = createTask() + + activateItem() + + await withTimeout(eatingTask.promise, CONSUME_TIMEOUT) + } + + function activateItem (offHand = false) { + bot.usingHeldItem = true + sequence++ + + if (bot.supportFeature('useItemWithBlockPlace')) { + bot._client.write('block_place', { + location: new Vec3(-1, 255, -1), + direction: -1, + heldItem: Item.toNotch(bot.heldItem), + cursorX: -1, + cursorY: -1, + cursorZ: -1 + }) + } else if (bot.supportFeature('useItemWithOwnPacket')) { + bot._client.write('use_item', { + hand: offHand ? 1 : 0, + sequence, + rotation: { x: 0, y: 0 } + }) + } + } + + function deactivateItem () { + const body = { + status: 5, + location: new Vec3(0, 0, 0), + face: 5 + } + + if (bot.supportFeature('useItemWithOwnPacket')) { + body.face = 0 + body.sequence = 0 + } + + bot._client.write('block_dig', body) + + bot.usingHeldItem = false + } + + async function putSelectedItemRange (start, end, window, slot) { + // put the selected item back indow the slot range in window + + // try to put it in an item that already exists and just increase + // the count. + + while (window.selectedItem) { + const item = window.findItemRange(start, end, window.selectedItem.type, window.selectedItem.metadata, true, window.selectedItem.nbt) + + if (item && item.stackSize !== item.count) { // something to join with + await clickWindow(item.slot, 0, 0) + } else { // nothing to join with + const emptySlot = window.firstEmptySlotRange(start, end) + if (emptySlot === null) { // no room left + if (slot === null) { // no room => drop it + await tossLeftover() + } else { // if there is still some leftover and slot is not null, click slot + await clickWindow(slot, 0, 0) + await tossLeftover() + } + } else { + await clickWindow(emptySlot, 0, 0) + } + } + } + + async function tossLeftover () { + if (window.selectedItem) { + await clickWindow(-999, 0, 0) + } + } + } + + async function activateBlock (block, direction, cursorPos) { + direction = direction ?? new Vec3(0, 1, 0) + const directionNum = vectorToDirection(direction) // The packet needs a number as the direction + cursorPos = cursorPos ?? new Vec3(0.5, 0.5, 0.5) + // TODO: tell the server that we are not sneaking while doing this + await bot.lookAt(block.position.offset(0.5, 0.5, 0.5), false) + // place block message + // TODO: logic below can likely be simplified + if (bot.supportFeature('blockPlaceHasHeldItem')) { + bot._client.write('block_place', { + location: block.position, + direction: directionNum, + heldItem: Item.toNotch(bot.heldItem), + cursorX: cursorPos.scaled(16).x, + cursorY: cursorPos.scaled(16).y, + cursorZ: cursorPos.scaled(16).z + }) + } else if (bot.supportFeature('blockPlaceHasHandAndIntCursor')) { + bot._client.write('block_place', { + location: block.position, + direction: directionNum, + hand: 0, + cursorX: cursorPos.scaled(16).x, + cursorY: cursorPos.scaled(16).y, + cursorZ: cursorPos.scaled(16).z + }) + } else if (bot.supportFeature('blockPlaceHasHandAndFloatCursor')) { + bot._client.write('block_place', { + location: block.position, + direction: directionNum, + hand: 0, + cursorX: cursorPos.x, + cursorY: cursorPos.y, + cursorZ: cursorPos.z + }) + } else if (bot.supportFeature('blockPlaceHasInsideBlock')) { + bot._client.write('block_place', { + location: block.position, + direction: directionNum, + hand: 0, + cursorX: cursorPos.x, + cursorY: cursorPos.y, + cursorZ: cursorPos.z, + insideBlock: false, + sequence: 0, // 1.19.0+ + worldBorderHit: false // 1.21.3+ + }) + } + + // swing arm animation + bot.swingArm() + } + + async function activateEntity (entity) { + // TODO: tell the server that we are not sneaking while doing this + await bot.lookAt(entity.position.offset(0, 1, 0), false) + bot._client.write('use_entity', { + target: entity.id, + mouse: 0, // interact with entity + sneaking: false, + hand: 0 // interact with the main hand + }) + } + + async function activateEntityAt (entity, position) { + // TODO: tell the server that we are not sneaking while doing this + await bot.lookAt(position, false) + bot._client.write('use_entity', { + target: entity.id, + mouse: 2, // interact with entity at + sneaking: false, + hand: 0, // interact with the main hand + x: position.x - entity.position.x, + y: position.y - entity.position.y, + z: position.z - entity.position.z + }) + } + + async function transfer (options) { + const window = options.window || bot.currentWindow || bot.inventory + const itemType = options.itemType + const metadata = options.metadata + const nbt = options.nbt + let count = (options.count === undefined || options.count === null) ? 1 : options.count + let firstSourceSlot = null + + // ranges + const sourceStart = options.sourceStart + const destStart = options.destStart + assert.notStrictEqual(sourceStart, null) + assert.notStrictEqual(destStart, null) + const sourceEnd = options.sourceEnd === null ? sourceStart + 1 : options.sourceEnd + const destEnd = options.destEnd === null ? destStart + 1 : options.destEnd + + await transferOne() + + async function transferOne () { + if (count === 0) { + await putSelectedItemRange(sourceStart, sourceEnd, window, firstSourceSlot) + return + } + if (!window.selectedItem || window.selectedItem.type !== itemType || + (metadata != null && window.selectedItem.metadata !== metadata) || + (nbt != null && window.selectedItem.nbt !== nbt)) { + // we are not holding the item we need. click it. + const sourceItem = window.findItemRange(sourceStart, sourceEnd, itemType, metadata, false, nbt) + const mcDataEntry = bot.registry.itemsArray.find(x => x.id === itemType) + assert(mcDataEntry, 'Invalid itemType') + if (!sourceItem) throw new Error(`Can't find ${mcDataEntry.name} in slots [${sourceStart} - ${sourceEnd}], (item id: ${itemType})`) + if (firstSourceSlot === null) firstSourceSlot = sourceItem.slot + // number of item that can be moved from that slot + await clickWindow(sourceItem.slot, 0, 0) + } + await clickDest() + + async function clickDest () { + assert.notStrictEqual(window.selectedItem.type, null) + assert.notStrictEqual(window.selectedItem.metadata, null) + let destItem + let destSlot + // special case for tossing + if (destStart === -999) { + destSlot = -999 + } else { + // find a non full item that we can drop into + destItem = window.findItemRange(destStart, destEnd, + window.selectedItem.type, window.selectedItem.metadata, true, nbt) + // if that didn't work find an empty slot to drop into + destSlot = destItem + ? destItem.slot + : window.firstEmptySlotRange(destStart, destEnd) + // if that didn't work, give up + if (destSlot === null) { + throw new Error('destination full') + } + } + // move the maximum number of item that can be moved + const destSlotCount = destItem && destItem.count ? destItem.count : 0 + const movedItems = Math.min(window.selectedItem.stackSize - destSlotCount, window.selectedItem.count) + // if the number of item the left click moves is less than the number of item we want to move + // several at the same time (left click) + if (movedItems <= count) { + await clickWindow(destSlot, 0, 0) + // update the number of item we want to move (count) + count -= movedItems + await transferOne() + } else { + // one by one (right click) + await clickWindow(destSlot, 1, 0) + count -= 1 + await transferOne() + } + } + } + } + + function extendWindow (window) { + window.close = () => { + closeWindow(window) + window.emit('close') + } + + window.withdraw = async (itemType, metadata, count, nbt) => { + if (bot.inventory.emptySlotCount() === 0) { + throw new Error('Unable to withdraw, Bot inventory is full.') + } + const options = { + window, + itemType, + metadata, + count, + nbt, + sourceStart: 0, + sourceEnd: window.inventoryStart, + destStart: window.inventoryStart, + destEnd: window.inventoryEnd + } + await transfer(options) + } + window.deposit = async (itemType, metadata, count, nbt) => { + const options = { + window, + itemType, + metadata, + count, + nbt, + sourceStart: window.inventoryStart, + sourceEnd: window.inventoryEnd, + destStart: 0, + destEnd: window.inventoryStart + } + await transfer(options) + } + } + + async function openBlock (block, direction, cursorPos) { + bot.activateBlock(block, direction, cursorPos) + const [window] = await once(bot, 'windowOpen') + extendWindow(window) + return window + } + + async function openEntity (entity) { + bot.activateEntity(entity) + const [window] = await once(bot, 'windowOpen') + extendWindow(window) + return window + } + + function createActionNumber () { + nextActionNumber = nextActionNumber === 32767 ? 1 : nextActionNumber + 1 + return nextActionNumber + } + + function updateHeldItem () { + bot.emit('heldItemChanged', bot.heldItem) + } + + function closeWindow (window) { + bot._client.write('close_window', { + windowId: window.id + }) + copyInventory(window) + bot.currentWindow = null + bot.emit('windowClose', window) + } + + function copyInventory (window) { + const slotOffset = window.inventoryStart - bot.inventory.inventoryStart + for (let i = window.inventoryStart; i < window.inventoryEnd; i++) { + const item = window.slots[i] + const slot = i - slotOffset + if (item) { + item.slot = slot + } + if (!Item.equal(bot.inventory.slots[slot], item, true)) bot.inventory.updateSlot(slot, item) + } + } + + function tradeMatch (limitItem, targetItem) { + return ( + targetItem !== null && + limitItem !== null && + targetItem.type === limitItem.type && + targetItem.count >= limitItem.count + ) + } + + function expectTradeUpdate (window) { + const trade = window.selectedTrade + const hasItem = !!window.slots[2] + + if (hasItem !== tradeMatch(trade.inputItem1, window.slots[0])) { + if (trade.hasItem2) { + return hasItem !== tradeMatch(trade.inputItem2, window.slots[1]) + } + return true + } + return false + } + + async function waitForWindowUpdate (window, slot) { + if (window.type === 'minecraft:inventory') { + if (slot >= 1 && slot <= 4) { + await once(bot.inventory, 'updateSlot:0') + } + } else if (window.type === 'minecraft:crafting') { + if (slot >= 1 && slot <= 9) { + await once(bot.currentWindow, 'updateSlot:0') + } + } else if (window.type === 'minecraft:merchant') { + const toUpdate = [] + if (slot <= 1 && !window.selectedTrade.tradeDisabled && expectTradeUpdate(window)) { + toUpdate.push(once(bot.currentWindow, 'updateSlot:2')) + } + if (slot === 2) { + for (const item of bot.currentWindow.containerItems()) { + toUpdate.push(once(bot.currentWindow, `updateSlot:${item.slot}`)) + } + } + await Promise.all(toUpdate) + + if (slot === 2 && !window.selectedTrade.tradeDisabled && expectTradeUpdate(window)) { + // After the trade goes through, if the inputs are still satisfied, + // expect another update in slot 2 + await once(bot.currentWindow, 'updateSlot:2') + } + } + } + + function confirmTransaction (windowId, actionId, accepted) { + // drop the queue entries for all the clicks that the server did not send + // transaction packets for. + // Also reject transactions that aren't sent from mineflayer + let click = windowClickQueue[0] + if (click === undefined || !windowClickQueue.some(clicks => clicks.id === actionId)) { + // mimic vanilla client and send a rejection for faulty transaction packets + bot._client.write('transaction', { + windowId, + action: actionId, + accepted: true + // bot.emit(`confirmTransaction${click.id}`, false) + }) + return + } + // shift it later if packets are sent out of order + click = windowClickQueue.shift() + + assert.ok(click.id <= actionId) + while (actionId > click.id) { + onAccepted() + click = windowClickQueue.shift() + } + assert.ok(click) + + if (accepted) { + onAccepted() + } else { + onRejected() + } + updateHeldItem() + + function onAccepted () { + const window = windowId === 0 ? bot.inventory : bot.currentWindow + if (!window || window.id !== click.windowId) return + window.acceptClick(click) + bot.emit(`confirmTransaction${click.id}`, true) + } + + function onRejected () { + bot._client.write('transaction', { + windowId: click.windowId, + action: click.id, + accepted: true + }) + bot.emit(`confirmTransaction${click.id}`, false) + } + } + + function getChangedSlots (oldSlots, newSlots) { + assert.equal(oldSlots.length, newSlots.length) + + const changedSlots = [] + + for (let i = 0; i < newSlots.length; i++) { + if (!Item.equal(oldSlots[i], newSlots[i])) { + changedSlots.push(i) + } + } + + return changedSlots + } + + async function clickWindow (slot, mouseButton, mode) { + // if you click on the quick bar and have dug recently, + // wait a bit + if (slot >= bot.QUICK_BAR_START && bot.lastDigTime != null) { + let timeSinceLastDig + while ((timeSinceLastDig = new Date() - bot.lastDigTime) < DIG_CLICK_TIMEOUT) { + await sleep(DIG_CLICK_TIMEOUT - timeSinceLastDig) + } + } + const window = bot.currentWindow || bot.inventory + + assert.ok(mode >= 0 && mode <= 4) + const actionId = createActionNumber() + + const click = { + slot, + mouseButton, + mode, + id: actionId, + windowId: window.id, + item: slot === -999 ? null : window.slots[slot] + } + + let changedSlots + if (bot.supportFeature('transactionPacketExists')) { + windowClickQueue.push(click) + } else { + if ( + // this array indicates the clicks that return changedSlots + [ + 0, + // 1, + // 2, + 3, + 4 + // 5, + // 6 + ].includes(click.mode)) { + changedSlots = window.acceptClick(click) + } else { + // this is used as a fallback + const oldSlots = JSON.parse(JSON.stringify(window.slots)) + + window.acceptClick(click) + + changedSlots = getChangedSlots(oldSlots, window.slots) + } + + changedSlots = changedSlots.map(slot => { + return { + location: slot, + item: Item.toNotch(window.slots[slot]) + } + }) + } + + // WHEN ADDING SUPPORT FOR OTHER CLICKS, MAKE SURE TO CHANGE changedSlots TO SUPPORT THEM + if (bot.supportFeature('stateIdUsed')) { // 1.17.1 + + bot._client.write('window_click', { + windowId: window.id, + stateId, + slot, + mouseButton, + mode, + changedSlots, + cursorItem: Item.toNotch(window.selectedItem) + }) + } else if (bot.supportFeature('actionIdUsed')) { // <= 1.16.5 + bot._client.write('window_click', { + windowId: window.id, + slot, + mouseButton, + action: actionId, + mode, + // protocol expects null even if there is an item at the slot in mode 2 and 4 + item: Item.toNotch((mode === 2 || mode === 4) ? null : click.item) + }) + } else { // 1.17 + bot._client.write('window_click', { + windowId: window.id, + slot, + mouseButton, + mode, + changedSlots, + cursorItem: Item.toNotch(window.selectedItem) + }) + } + + if (bot.supportFeature('transactionPacketExists')) { + const response = once(bot, `confirmTransaction${actionId}`) + if (!window.transactionRequiresConfirmation(click)) { + confirmTransaction(window.id, actionId, true) + } + const [success] = await withTimeout(response, WINDOW_TIMEOUT) + .catch(() => { + throw new Error(`Server didn't respond to transaction for clicking on slot ${slot} on window with id ${window?.id}.`) + }) + if (!success) { + throw new Error(`Server rejected transaction for clicking on slot ${slot}, on window with id ${window?.id}.`) + } + } else { + await waitForWindowUpdate(window, slot) + } + } + + async function putAway (slot) { + const window = bot.currentWindow || bot.inventory + const promisePutAway = once(window, `updateSlot:${slot}`) + await clickWindow(slot, 0, 0) + const start = window.inventoryStart + const end = window.inventoryEnd + await putSelectedItemRange(start, end, window, null) + await promisePutAway + } + + async function moveSlotItem (sourceSlot, destSlot) { + await clickWindow(sourceSlot, 0, 0) + await clickWindow(destSlot, 0, 0) + // if we're holding an item, put it back where the source item was. + // otherwise we're done. + updateHeldItem() + if (bot.inventory.selectedItem) { + await clickWindow(sourceSlot, 0, 0) + } + } + + bot._client.on('transaction', (packet) => { + // confirm transaction + confirmTransaction(packet.windowId, packet.action, packet.accepted) + }) + + bot._client.on('held_item_slot', (packet) => { + // held item change + bot.setQuickBarSlot(packet.slot) + }) + + function prepareWindow (window) { + if (!windowItems || window.id !== windowItems.windowId) { + // don't emit windowOpen until we have the slot data + bot.once(`setWindowItems:${window.id}`, () => { + extendWindow(window) + bot.emit('windowOpen', window) + }) + } else { + for (let i = 0; i < windowItems.items.length; ++i) { + const item = Item.fromNotch(windowItems.items[i]) + window.updateSlot(i, item) + } + updateHeldItem() + extendWindow(window) + bot.emit('windowOpen', window) + } + } + + bot._client.on('open_window', (packet) => { + // open window + bot.currentWindow = windows.createWindow(packet.windowId, + packet.inventoryType, packet.windowTitle, packet.slotCount) + prepareWindow(bot.currentWindow) + }) + bot._client.on('open_horse_window', (packet) => { + // open window + bot.currentWindow = windows.createWindow(packet.windowId, + 'HorseWindow', 'Horse', packet.nbSlots) + prepareWindow(bot.currentWindow) + }) + bot._client.on('close_window', (packet) => { + // close window + const oldWindow = bot.currentWindow + bot.currentWindow = null + bot.emit('windowClose', oldWindow) + }) + bot._client.on('login', () => { + // close window when switch subserver + const oldWindow = bot.currentWindow + if (!oldWindow) return + bot.currentWindow = null + bot.emit('windowClose', oldWindow) + }) + bot._setSlot = (slotId, newItem, window = bot.inventory) => { + // set slot + const oldItem = window.slots[slotId] + window.updateSlot(slotId, newItem) + updateHeldItem() + bot.emit(`setSlot:${window.id}`, oldItem, newItem) + } + bot._client.on('set_slot', (packet) => { + const window = packet.windowId === 0 ? bot.inventory : bot.currentWindow + if (!window || window.id !== packet.windowId) return + const newItem = Item.fromNotch(packet.item) + bot._setSlot(packet.slot, newItem, window) + }) + bot._client.on('window_items', (packet) => { + const window = packet.windowId === 0 ? bot.inventory : bot.currentWindow + if (!window || window.id !== packet.windowId) { + windowItems = packet + return + } + + // set window items + for (let i = 0; i < packet.items.length; ++i) { + const item = Item.fromNotch(packet.items[i]) + window.updateSlot(i, item) + } + updateHeldItem() + bot.emit(`setWindowItems:${window.id}`) + }) + + /** + * Convert a vector direction to minecraft packet number direction + * @param {Vec3} v + * @returns {number} + */ + function vectorToDirection (v) { + if (v.y < 0) { + return 0 + } else if (v.y > 0) { + return 1 + } else if (v.z < 0) { + return 2 + } else if (v.z > 0) { + return 3 + } else if (v.x < 0) { + return 4 + } else if (v.x > 0) { + return 5 + } + assert.ok(false, `invalid direction vector ${v}`) + } + + bot.activateBlock = activateBlock + bot.activateEntity = activateEntity + bot.activateEntityAt = activateEntityAt + bot.consume = consume + bot.activateItem = activateItem + bot.deactivateItem = deactivateItem + + // not really in the public API + bot.clickWindow = clickWindow + bot.putSelectedItemRange = putSelectedItemRange + bot.putAway = putAway + bot.closeWindow = closeWindow + bot.transfer = transfer + bot.openBlock = openBlock + bot.openEntity = openEntity + bot.moveSlotItem = moveSlotItem + bot.updateHeldItem = updateHeldItem +} diff --git a/bridge/lib/mineflayer/lib/plugins/kick.js b/bridge/lib/mineflayer/lib/plugins/kick.js new file mode 100644 index 0000000..cb23e68 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/kick.js @@ -0,0 +1,14 @@ +module.exports = inject + +function inject (bot) { + bot._client.on('kick_disconnect', (packet) => { + bot.emit('kicked', packet.reason, true) + }) + bot._client.on('disconnect', (packet) => { + bot.emit('kicked', packet.reason, false) + }) + bot.quit = (reason) => { + reason = reason ?? 'disconnect.quitting' + bot.end(reason) + } +} diff --git a/bridge/lib/mineflayer/lib/plugins/particle.js b/bridge/lib/mineflayer/lib/plugins/particle.js new file mode 100644 index 0000000..00cc95a --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/particle.js @@ -0,0 +1,9 @@ +module.exports = inject + +function inject (bot, { version }) { + const Particle = require('../particle')(bot.registry) + + bot._client.on('world_particles', (packet) => { + bot.emit('particle', Particle.fromNetwork(packet)) + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/physics.js b/bridge/lib/mineflayer/lib/plugins/physics.js new file mode 100644 index 0000000..0bcf679 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/physics.js @@ -0,0 +1,453 @@ +const { Vec3 } = require('vec3') +const assert = require('assert') +const math = require('../math') +const conv = require('../conversions') +const { performance } = require('perf_hooks') +const { createDoneTask, createTask } = require('../promise_utils') + +const { Physics, PlayerState } = require('prismarine-physics') + +module.exports = inject + +const PI = Math.PI +const PI_2 = Math.PI * 2 +const PHYSICS_INTERVAL_MS = 50 +const PHYSICS_TIMESTEP = PHYSICS_INTERVAL_MS / 1000 // 0.05 + +function inject (bot, { physicsEnabled, maxCatchupTicks }) { + const PHYSICS_CATCHUP_TICKS = maxCatchupTicks ?? 4 + const world = { getBlock: (pos) => { return bot.blockAt(pos, false) } } + const physics = Physics(bot.registry, world) + + const positionUpdateSentEveryTick = bot.supportFeature('positionUpdateSentEveryTick') + + bot.jumpQueued = false + bot.jumpTicks = 0 // autojump cooldown + + const controlState = { + forward: false, + back: false, + left: false, + right: false, + jump: false, + sprint: false, + sneak: false + } + let lastSentYaw = null + let lastSentPitch = null + let doPhysicsTimer = null + let lastPhysicsFrameTime = null + let shouldUsePhysics = false + bot.physicsEnabled = physicsEnabled ?? true + let deadTicks = 21 + + const lastSent = { + x: 0, + y: 0, + z: 0, + yaw: 0, + pitch: 0, + onGround: false, + time: 0, + flags: { onGround: false, hasHorizontalCollision: false } + } + + // This function should be executed each tick (every 0.05 seconds) + // How it works: https://gafferongames.com/post/fix_your_timestep/ + + // WARNING: THIS IS NOT ACCURATE ON WINDOWS (15.6 Timer Resolution) + // use WSL or switch to Linux + // see: https://discord.com/channels/413438066984747026/519952494768685086/901948718255833158 + let timeAccumulator = 0 + let catchupTicks = 0 + function doPhysics () { + const now = performance.now() + const deltaSeconds = (now - lastPhysicsFrameTime) / 1000 + lastPhysicsFrameTime = now + + timeAccumulator += deltaSeconds + catchupTicks = 0 + while (timeAccumulator >= PHYSICS_TIMESTEP) { + tickPhysics(now) + timeAccumulator -= PHYSICS_TIMESTEP + catchupTicks++ + if (catchupTicks >= PHYSICS_CATCHUP_TICKS) break + } + } + + function tickPhysics (now) { + if (bot.blockAt(bot.entity.position) == null) return // check if chunk is unloaded + if (bot.physicsEnabled && shouldUsePhysics) { + physics.simulatePlayer(new PlayerState(bot, controlState), world).apply(bot) + bot.emit('physicsTick') + bot.emit('physicTick') // Deprecated, only exists to support old plugins. May be removed in the future + } + if (shouldUsePhysics) { + updatePosition(now) + } + } + + // remove this when 'physicTick' is removed + bot.on('newListener', (name) => { + if (name === 'physicTick') console.warn('Mineflayer detected that you are using a deprecated event (physicTick)! Please use this event (physicsTick) instead.') + }) + + function cleanup () { + clearInterval(doPhysicsTimer) + doPhysicsTimer = null + } + + function sendPacketPosition (position, onGround) { + // sends data, no logic + const oldPos = new Vec3(lastSent.x, lastSent.y, lastSent.z) + lastSent.x = position.x + lastSent.y = position.y + lastSent.z = position.z + lastSent.onGround = onGround + lastSent.flags = { onGround, hasHorizontalCollision: undefined } // 1.21.3+ + bot._client.write('position', lastSent) + bot.emit('move', oldPos) + } + + function sendPacketLook (yaw, pitch, onGround) { + // sends data, no logic + const oldPos = new Vec3(lastSent.x, lastSent.y, lastSent.z) + lastSent.yaw = yaw + lastSent.pitch = pitch + lastSent.onGround = onGround + lastSent.flags = { onGround, hasHorizontalCollision: undefined } // 1.21.3+ + bot._client.write('look', lastSent) + bot.emit('move', oldPos) + } + + function sendPacketPositionAndLook (position, yaw, pitch, onGround) { + // sends data, no logic + const oldPos = new Vec3(lastSent.x, lastSent.y, lastSent.z) + lastSent.x = position.x + lastSent.y = position.y + lastSent.z = position.z + lastSent.yaw = yaw + lastSent.pitch = pitch + lastSent.onGround = onGround + lastSent.flags = { onGround, hasHorizontalCollision: undefined } // 1.21.3+ + bot._client.write('position_look', lastSent) + bot.emit('move', oldPos) + } + + function deltaYaw (yaw1, yaw2) { + let dYaw = (yaw1 - yaw2) % PI_2 + if (dYaw < -PI) dYaw += PI_2 + else if (dYaw > PI) dYaw -= PI_2 + + return dYaw + } + + // returns false if bot should send position packets + function isEntityRemoved () { + if (bot.isAlive === true) deadTicks = 0 + if (bot.isAlive === false && deadTicks <= 20) deadTicks++ + if (deadTicks >= 20) return true + return false + } + + function updatePosition (now) { + // Only send updates for 20 ticks after death + if (isEntityRemoved()) return + + // Increment the yaw in baby steps so that notchian clients (not the server) can keep up. + const dYaw = deltaYaw(bot.entity.yaw, lastSentYaw) + const dPitch = bot.entity.pitch - (lastSentPitch || 0) + + // Vanilla doesn't clamp yaw, so we don't want to do it either + const maxDeltaYaw = PHYSICS_TIMESTEP * physics.yawSpeed + const maxDeltaPitch = PHYSICS_TIMESTEP * physics.pitchSpeed + lastSentYaw += math.clamp(-maxDeltaYaw, dYaw, maxDeltaYaw) + lastSentPitch += math.clamp(-maxDeltaPitch, dPitch, maxDeltaPitch) + + const yaw = Math.fround(conv.toNotchianYaw(lastSentYaw)) + const pitch = Math.fround(conv.toNotchianPitch(lastSentPitch)) + const position = bot.entity.position + const onGround = bot.entity.onGround + + // Only send a position update if necessary, select the appropriate packet + const positionUpdated = lastSent.x !== position.x || lastSent.y !== position.y || lastSent.z !== position.z || + // Send a position update every second, even if no other update was made + // This function rounds to the nearest 50ms (or PHYSICS_INTERVAL_MS) and checks if a second has passed. + (Math.round((now - lastSent.time) / PHYSICS_INTERVAL_MS) * PHYSICS_INTERVAL_MS) >= 1000 + const lookUpdated = lastSent.yaw !== yaw || lastSent.pitch !== pitch + + if (positionUpdated && lookUpdated) { + sendPacketPositionAndLook(position, yaw, pitch, onGround) + lastSent.time = now // only reset if positionUpdated is true + } else if (positionUpdated) { + sendPacketPosition(position, onGround) + lastSent.time = now // only reset if positionUpdated is true + } else if (lookUpdated) { + sendPacketLook(yaw, pitch, onGround) + } else if (positionUpdateSentEveryTick || onGround !== lastSent.onGround) { + // For versions < 1.12, one player packet should be sent every tick + // for the server to update health correctly + // For versions >= 1.12, onGround !== lastSent.onGround should be used, but it doesn't ever trigger outside of login + bot._client.write('flying', { + onGround: bot.entity.onGround, + flags: { onGround: bot.entity.onGround, hasHorizontalCollision: undefined } // 1.21.3+ + }) + } + + lastSent.onGround = bot.entity.onGround // onGround is always set + } + + bot.physics = physics + + function getEffectLevel (mcData, effectName, effects) { + const effectDescriptor = mcData.effectsByName[effectName] + if (!effectDescriptor) { + return 0 + } + const effectInfo = effects[effectDescriptor.id] + if (!effectInfo) { + return 0 + } + return effectInfo.amplifier + 1 + } + + bot.elytraFly = async () => { + if (bot.entity.elytraFlying) { + throw new Error('Already elytra flying') + } else if (bot.entity.onGround) { + throw new Error('Unable to fly from ground') + } else if (bot.entity.isInWater) { + throw new Error('Unable to elytra fly while in water') + } + + const mcData = require('minecraft-data')(bot.version) + if (getEffectLevel(mcData, 'Levitation', bot.entity.effects) > 0) { + throw new Error('Unable to elytra fly with levitation effect') + } + + const torsoSlot = bot.getEquipmentDestSlot('torso') + const item = bot.inventory.slots[torsoSlot] + if (item == null || item.name !== 'elytra') { + throw new Error('Elytra must be equip to start flying') + } + bot._client.write('entity_action', { + entityId: bot.entity.id, + actionId: bot.supportFeature('entityActionUsesStringMapper') ? 'start_elytra_flying' : 8, + jumpBoost: 0 + }) + } + + bot.setControlState = (control, state) => { + assert.ok(control in controlState, `invalid control: ${control}`) + assert.ok(typeof state === 'boolean', `invalid state: ${state}`) + if (controlState[control] === state) return + controlState[control] = state + if (control === 'jump' && state) { + bot.jumpQueued = true + } else if (control === 'sprint') { + bot._client.write('entity_action', { + entityId: bot.entity.id, + actionId: bot.supportFeature('entityActionUsesStringMapper') + ? (state ? 'start_sprinting' : 'stop_sprinting') + : (state ? 3 : 4), + jumpBoost: 0 + }) + } else if (control === 'sneak') { + if (bot.supportFeature('newPlayerInputPacket')) { + // In 1.21.6+, sneak is handled via player_input packet + bot._client.write('player_input', { + inputs: { + shift: state + } + }) + } else { + // Legacy entity_action approach for older versions + bot._client.write('entity_action', { + entityId: bot.entity.id, + actionId: state ? 0 : 1, + jumpBoost: 0 + }) + } + } + } + + bot.getControlState = (control) => { + assert.ok(control in controlState, `invalid control: ${control}`) + return controlState[control] + } + + bot.clearControlStates = () => { + for (const control in controlState) { + bot.setControlState(control, false) + } + } + + bot.controlState = {} + + for (const control of Object.keys(controlState)) { + Object.defineProperty(bot.controlState, control, { + get () { + return controlState[control] + }, + set (state) { + bot.setControlState(control, state) + return state + } + }) + } + + let lookingTask = createDoneTask() + + bot.on('move', () => { + if (!lookingTask.done && Math.abs(deltaYaw(bot.entity.yaw, lastSentYaw)) < 0.001) { + lookingTask.finish() + } + }) + + bot._client.on('explosion', explosion => { + // TODO: emit an explosion event with more info + if (bot.physicsEnabled && bot.game.gameMode !== 'creative') { + if (explosion.playerKnockback) { // 1.21.3+ + // Fixes issue #3635 + bot.entity.velocity.x += explosion.playerKnockback.x + bot.entity.velocity.y += explosion.playerKnockback.y + bot.entity.velocity.z += explosion.playerKnockback.z + } + if ('playerMotionX' in explosion) { + bot.entity.velocity.x += explosion.playerMotionX + bot.entity.velocity.y += explosion.playerMotionY + bot.entity.velocity.z += explosion.playerMotionZ + } + } + }) + + bot.look = async (yaw, pitch, force) => { + if (!lookingTask.done) { + lookingTask.finish() // finish the previous one + } + lookingTask = createTask() + + // this is done to bypass certain anticheat checks that detect the player's sensitivity + // by calculating the gcd of how much they move the mouse each tick + const sensitivity = conv.fromNotchianPitch(0.15) // this is equal to 100% sensitivity in vanilla + const yawChange = Math.round((yaw - bot.entity.yaw) / sensitivity) * sensitivity + const pitchChange = Math.round((pitch - bot.entity.pitch) / sensitivity) * sensitivity + + if (yawChange === 0 && pitchChange === 0) { + return + } + + bot.entity.yaw += yawChange + bot.entity.pitch += pitchChange + + if (force) { + lastSentYaw = yaw + lastSentPitch = pitch + return + } + + await lookingTask.promise + } + + bot.lookAt = async (point, force) => { + const delta = point.minus(bot.entity.position.offset(0, bot.entity.eyeHeight, 0)) + const yaw = Math.atan2(-delta.x, -delta.z) + const groundDistance = Math.sqrt(delta.x * delta.x + delta.z * delta.z) + const pitch = Math.atan2(delta.y, groundDistance) + await bot.look(yaw, pitch, force) + } + + // 1.21.3+ + bot._client.on('player_rotation', (packet) => { + bot.entity.yaw = conv.fromNotchianYaw(packet.yaw) + bot.entity.pitch = conv.fromNotchianPitch(packet.pitch) + }) + + // player position and look (clientbound) + bot._client.on('position', (packet) => { + // Is this necessary? Feels like it might wrongly overwrite hitbox size sometimes + // e.g. when crouching/crawling/swimming. Can someone confirm? + bot.entity.height = 1.8 + + const vel = bot.entity.velocity + const pos = bot.entity.position + let newYaw, newPitch + + // Note: 1.20.5+ uses a bitflags object, older versions use a bitmask number + if (typeof packet.flags === 'object') { + // Modern path with bitflags object + // Velocity is only set to 0 if the flag is not set, otherwise keep current velocity + vel.set( + packet.flags.x ? vel.x : 0, + packet.flags.y ? vel.y : 0, + packet.flags.z ? vel.z : 0 + ) + // If flag is set, then the corresponding value is relative, else it is absolute + pos.set( + packet.flags.x ? (pos.x + packet.x) : packet.x, + packet.flags.y ? (pos.y + packet.y) : packet.y, + packet.flags.z ? (pos.z + packet.z) : packet.z + ) + newYaw = (packet.flags.yaw ? conv.toNotchianYaw(bot.entity.yaw) : 0) + packet.yaw + newPitch = (packet.flags.pitch ? conv.toNotchianPitch(bot.entity.pitch) : 0) + packet.pitch + } else { + // Legacy path with bitmask number + // Velocity is only set to 0 if the flag is not set, otherwise keep current velocity + vel.set( + packet.flags & 1 ? vel.x : 0, + packet.flags & 2 ? vel.y : 0, + packet.flags & 4 ? vel.z : 0 + ) + // If flag is set, then the corresponding value is relative, else it is absolute + pos.set( + packet.flags & 1 ? (pos.x + packet.x) : packet.x, + packet.flags & 2 ? (pos.y + packet.y) : packet.y, + packet.flags & 4 ? (pos.z + packet.z) : packet.z + ) + newYaw = (packet.flags & 8 ? conv.toNotchianYaw(bot.entity.yaw) : 0) + packet.yaw + newPitch = (packet.flags & 16 ? conv.toNotchianPitch(bot.entity.pitch) : 0) + packet.pitch + } + + bot.entity.yaw = conv.fromNotchianYaw(newYaw) + bot.entity.pitch = conv.fromNotchianPitch(newPitch) + bot.entity.onGround = false + + if (bot.supportFeature('teleportUsesOwnPacket')) { + bot._client.write('teleport_confirm', { teleportId: packet.teleportId }) + } + sendPacketPositionAndLook(pos, newYaw, newPitch, bot.entity.onGround) + + shouldUsePhysics = true + bot.jumpTicks = 0 + lastSentYaw = bot.entity.yaw + lastSentPitch = bot.entity.pitch + + bot.emit('forcedMove') + }) + + bot.waitForTicks = async function (ticks) { + if (ticks <= 0) return + await new Promise(resolve => { + const tickListener = () => { + ticks-- + if (ticks === 0) { + bot.removeListener('physicsTick', tickListener) + resolve() + } + } + + bot.on('physicsTick', tickListener) + }) + } + + bot.on('mount', () => { shouldUsePhysics = false }) + bot.on('respawn', () => { shouldUsePhysics = false }) + bot.on('login', () => { + shouldUsePhysics = false + if (doPhysicsTimer === null) { + lastPhysicsFrameTime = performance.now() + doPhysicsTimer = setInterval(doPhysics, PHYSICS_INTERVAL_MS) + } + }) + bot.on('end', cleanup) +} diff --git a/bridge/lib/mineflayer/lib/plugins/place_block.js b/bridge/lib/mineflayer/lib/plugins/place_block.js new file mode 100644 index 0000000..fdaec6b --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/place_block.js @@ -0,0 +1,38 @@ +const { onceWithCleanup } = require('../promise_utils') + +module.exports = inject + +function inject (bot) { + async function placeBlockWithOptions (referenceBlock, faceVector, options) { + const dest = referenceBlock.position.plus(faceVector) + let oldBlock = bot.blockAt(dest) + await bot._genericPlace(referenceBlock, faceVector, options) + + let newBlock = bot.blockAt(dest) + if (oldBlock.type === newBlock.type) { + [oldBlock, newBlock] = await onceWithCleanup(bot, `blockUpdate:${dest}`, { + timeout: 5000, + // Condition to wait to receive block update actually changing the block type, in case the bot receives block updates with no changes + // oldBlock and newBlock will both be null when the world unloads + checkCondition: (oldBlock, newBlock) => !oldBlock || !newBlock || oldBlock.type !== newBlock.type + }) + } + + // blockUpdate emits (null, null) when the world unloads + if (!oldBlock && !newBlock) { + return + } + if (oldBlock?.type === newBlock.type) { + throw new Error(`No block has been placed : the block is still ${oldBlock?.name}`) + } else { + bot.emit('blockPlaced', oldBlock, newBlock) + } + } + + async function placeBlock (referenceBlock, faceVector) { + await placeBlockWithOptions(referenceBlock, faceVector, { swingArm: 'right' }) + } + + bot.placeBlock = placeBlock + bot._placeBlockWithOptions = placeBlockWithOptions +} diff --git a/bridge/lib/mineflayer/lib/plugins/place_entity.js b/bridge/lib/mineflayer/lib/plugins/place_entity.js new file mode 100644 index 0000000..c7b750a --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/place_entity.js @@ -0,0 +1,109 @@ +const assert = require('assert') + +module.exports = inject + +function inject (bot) { + const Item = require('prismarine-item')(bot.registry) + + /** + * + * @param {import('prismarine-block').Block} referenceBlock + * @param {import('vec3').Vec3} faceVector + * @param {{forceLook?: boolean | 'ignore', offhand?: boolean, swingArm?: 'right' | 'left', showHand?: boolean}} options + */ + async function placeEntityWithOptions (referenceBlock, faceVector, options) { + if (!bot.heldItem) throw new Error('must be holding an item to place an entity') + + const type = bot.heldItem.name // used for assert + .replace(/.+_boat/, 'boat') + .replace(/.+_spawn_egg/, 'spawn_egg') + assert(['end_crystal', 'boat', 'spawn_egg', 'armor_stand'].includes(type), 'Unimplemented') + + let name = bot.heldItem.name // used for finding entity after spawn + .replace(/.+_boat/, 'boat') + + if (name.endsWith('spawn_egg')) { + name = bot.heldItem.spawnEggMobName + } + + if (type === 'spawn_egg') { + options.showHand = false + } + + if (!options.swingArm) options.swingArm = options.offhand ? 'left' : 'right' + + const pos = await bot._genericPlace(referenceBlock, faceVector, options) + + if (type === 'boat') { + if (bot.supportFeature('useItemWithOwnPacket')) { + bot._client.write('use_item', { + hand: options.offhand ? 1 : 0 + }) + } else { + bot._client.write('block_place', { + location: { x: -1, y: -1, z: -1 }, + direction: -1, + heldItem: Item.toNotch(bot.heldItem), + cursorX: 0, + cursorY: 0, + cursorZ: 0 + }) + } + } + + const dest = pos.plus(faceVector) + const entity = await waitForEntitySpawn(name, dest) + bot.emit('entityPlaced', entity) + return entity + } + + async function placeEntity (referenceBlock, faceVector) { + return await placeEntityWithOptions(referenceBlock, faceVector, {}) + } + + function waitForEntitySpawn (name, placePosition) { + const maxDistance = name === 'bat' ? 4 : name === 'boat' ? 3 : 2 + let mobName = name + if (name === 'end_crystal') { + if (bot.supportFeature('enderCrystalNameEndsInErNoCaps')) { + mobName = 'ender_crystal' + } else if (bot.supportFeature('entityNameLowerCaseNoUnderscore')) { + mobName = 'endercrystal' + } else if (bot.supportFeature('enderCrystalNameNoCapsWithUnderscore')) { + mobName = 'end_crystal' + } else { + mobName = 'EnderCrystal' + } + } else if (name === 'boat') { + mobName = bot.supportFeature('entityNameUpperCaseNoUnderscore') ? 'Boat' : 'boat' + } else if (name === 'armor_stand') { + if (bot.supportFeature('entityNameUpperCaseNoUnderscore')) { + mobName = 'ArmorStand' + } else if (bot.supportFeature('entityNameLowerCaseNoUnderscore')) { + mobName = 'armorstand' + } else { + mobName = 'armor_stand' + } + } + + return new Promise((resolve, reject) => { + function listener (entity) { + const dist = entity.position.distanceTo(placePosition) + if (entity.name === mobName && dist < maxDistance) { + resolve(entity) + } + bot.off('entitySpawn', listener) + } + + setTimeout(() => { + bot.off('entitySpawn', listener) + reject(new Error('Failed to place entity')) + }, 5000) // reject after 5s + + bot.on('entitySpawn', listener) + }) + } + + bot.placeEntity = placeEntity + bot._placeEntityWithOptions = placeEntityWithOptions +} diff --git a/bridge/lib/mineflayer/lib/plugins/rain.js b/bridge/lib/mineflayer/lib/plugins/rain.js new file mode 100644 index 0000000..c38c286 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/rain.js @@ -0,0 +1,24 @@ +module.exports = inject +const states = ['no_respawn_block_available', 'start_raining', 'stop_raining', 'change_game_mode', 'win_game', 'demo_event', 'play_arrow_hit_sound', 'rain_level_change', 'thunder_level_change', 'puffer_fish_sting', 'guardian_elder_effect', 'immediate_respawn', 'limited_crafting', 'level_chunks_load_start'] + +function inject (bot) { + bot.isRaining = false + bot.thunderState = 0 + bot.rainState = 0 + bot._client.on('game_state_change', (packet) => { + const reason = states[packet.reason] ?? packet.reason + if (reason === 'start_raining') { + bot.isRaining = true + bot.emit('rain') + } else if (reason === 'stop_raining') { + bot.isRaining = false + bot.emit('rain') + } else if (reason === 'rain_level_change') { + bot.rainState = packet.gameMode + bot.emit('weatherUpdate') + } else if (reason === 'thunder_level_change') { + bot.thunderState = packet.gameMode + bot.emit('weatherUpdate') + } + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/ray_trace.js b/bridge/lib/mineflayer/lib/plugins/ray_trace.js new file mode 100644 index 0000000..e45c83f --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/ray_trace.js @@ -0,0 +1,66 @@ +const { Vec3 } = require('vec3') +const { RaycastIterator } = require('prismarine-world').iterators + +module.exports = (bot) => { + function getViewDirection (pitch, yaw) { + const csPitch = Math.cos(pitch) + const snPitch = Math.sin(pitch) + const csYaw = Math.cos(yaw) + const snYaw = Math.sin(yaw) + return new Vec3(-snYaw * csPitch, snPitch, -csYaw * csPitch) + } + + bot.blockInSight = (maxSteps = 256, vectorLength = 5 / 16) => { + const block = bot.blockAtCursor(maxSteps * vectorLength) + if (block) return block + } + + bot.blockAtCursor = (maxDistance = 256, matcher = null) => { + return bot.blockAtEntityCursor(bot.entity, maxDistance, matcher) + } + + bot.entityAtCursor = (maxDistance = 3.5) => { + const block = bot.blockAtCursor(maxDistance) + maxDistance = block?.intersect.distanceTo(bot.entity.position) ?? maxDistance + + const entities = Object.values(bot.entities) + .filter(entity => entity.type !== 'object' && entity.username !== bot.username && entity.position.distanceTo(bot.entity.position) <= maxDistance) + + const dir = new Vec3(-Math.sin(bot.entity.yaw) * Math.cos(bot.entity.pitch), Math.sin(bot.entity.pitch), -Math.cos(bot.entity.yaw) * Math.cos(bot.entity.pitch)) + const iterator = new RaycastIterator(bot.entity.position.offset(0, bot.entity.eyeHeight, 0), dir.normalize(), maxDistance) + + let targetEntity = null + let targetDist = maxDistance + + for (let i = 0; i < entities.length; i++) { + const entity = entities[i] + const w = entity.width / 2 + + const shapes = [[-w, 0, -w, w, entity.height, w]] + const intersect = iterator.intersect(shapes, entity.position) + if (intersect) { + const entityDir = entity.position.minus(bot.entity.position) // Can be combined into 1 line + const sign = Math.sign(entityDir.dot(dir)) + if (sign !== -1) { + const dist = bot.entity.position.distanceTo(intersect.pos) + if (dist < targetDist) { + targetEntity = entity + targetDist = dist + } + } + } + } + + return targetEntity + } + + bot.blockAtEntityCursor = (entity = bot.entity, maxDistance = 256, matcher = null) => { + if (!entity.position || !entity.height || !entity.pitch || !entity.yaw) return null + const { position, height, pitch, yaw } = entity + + const eyePosition = position.offset(0, height, 0) + const viewDirection = getViewDirection(pitch, yaw) + + return bot.world.raycast(eyePosition, viewDirection, maxDistance, matcher) + } +} diff --git a/bridge/lib/mineflayer/lib/plugins/resource_pack.js b/bridge/lib/mineflayer/lib/plugins/resource_pack.js new file mode 100644 index 0000000..fc70ce3 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/resource_pack.js @@ -0,0 +1,94 @@ +const UUID = require('uuid-1345') + +module.exports = inject + +function inject (bot) { + let uuid + let latestHash + let latestUUID + let activeResourcePacks = {} + const TEXTURE_PACK_RESULTS = { + SUCCESSFULLY_LOADED: 0, + DECLINED: 1, + FAILED_DOWNLOAD: 2, + ACCEPTED: 3 + } + + bot._client.on('add_resource_pack', (data) => { // Emits the same as resource_pack_send but sends uuid rather than hash because that's how active packs are tracked + const uuid = new UUID(data.uuid) + // Adding the pack to a set by uuid + latestUUID = uuid + activeResourcePacks[uuid] = data.url + + bot.emit('resourcePack', data.url, uuid) + }) + + bot._client.on('remove_resource_pack', (data) => { // Doesn't emit anything because it is removing rather than adding + // if uuid isn't provided remove all packs + if (data.uuid === undefined) { + activeResourcePacks = {} + } else { + // Try to remove uuid from set + try { + delete activeResourcePacks[new UUID(data.uuid)] + } catch (error) { + console.error('Tried to remove UUID but it was not in the active list.') + } + } + }) + + bot._client.on('resource_pack_send', (data) => { + if (bot.supportFeature('resourcePackUsesUUID')) { + uuid = new UUID(data.uuid) + bot.emit('resourcePack', uuid, data.url) + latestUUID = uuid + } else { + bot.emit('resourcePack', data.url, data.hash) + latestHash = data.hash + } + }) + + function acceptResourcePack () { + if (bot.supportFeature('resourcePackUsesHash')) { + bot._client.write('resource_pack_receive', { + result: TEXTURE_PACK_RESULTS.ACCEPTED, + hash: latestHash + }) + bot._client.write('resource_pack_receive', { + result: TEXTURE_PACK_RESULTS.SUCCESSFULLY_LOADED, + hash: latestHash + }) + } else if (bot.supportFeature('resourcePackUsesUUID')) { + bot._client.write('resource_pack_receive', { + uuid: latestUUID, + result: TEXTURE_PACK_RESULTS.ACCEPTED + }) + bot._client.write('resource_pack_receive', { + uuid: latestUUID, + result: TEXTURE_PACK_RESULTS.SUCCESSFULLY_LOADED + }) + } else { + bot._client.write('resource_pack_receive', { + result: TEXTURE_PACK_RESULTS.ACCEPTED + }) + bot._client.write('resource_pack_receive', { + result: TEXTURE_PACK_RESULTS.SUCCESSFULLY_LOADED + }) + } + } + + function denyResourcePack () { + if (bot.supportFeature('resourcePackUsesUUID')) { + bot._client.write('resource_pack_receive', { + uuid: latestUUID, + result: TEXTURE_PACK_RESULTS.DECLINED + }) + } + bot._client.write('resource_pack_receive', { + result: TEXTURE_PACK_RESULTS.DECLINED + }) + } + + bot.acceptResourcePack = acceptResourcePack + bot.denyResourcePack = denyResourcePack +} diff --git a/bridge/lib/mineflayer/lib/plugins/scoreboard.js b/bridge/lib/mineflayer/lib/plugins/scoreboard.js new file mode 100644 index 0000000..c36bb09 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/scoreboard.js @@ -0,0 +1,75 @@ +module.exports = inject + +function inject (bot) { + const ScoreBoard = require('../scoreboard')(bot) + const scoreboards = {} + + bot._client.on('scoreboard_objective', (packet) => { + if (packet.action === 0) { + const { name } = packet + const scoreboard = new ScoreBoard(packet) + scoreboards[name] = scoreboard + + bot.emit('scoreboardCreated', scoreboard) + } + + if (packet.action === 1) { + bot.emit('scoreboardDeleted', scoreboards[packet.name]) + delete scoreboards[packet.name] + + for (const position in ScoreBoard.positions) { + if (!ScoreBoard.positions[position]) continue + const scoreboard = ScoreBoard.positions[position] + + if (scoreboard && scoreboard.name === packet.name) { + delete ScoreBoard.positions[position] + break + } + } + } + + if (packet.action === 2) { + if (!Object.hasOwn(scoreboards, packet.name)) { + bot.emit('error', new Error(`Received update for unknown objective ${packet.name}`)) + return + } + scoreboards[packet.name].setTitle(packet.displayText) + bot.emit('scoreboardTitleChanged', scoreboards[packet.name]) + } + }) + + bot._client.on('scoreboard_score', (packet) => { + const scoreboard = scoreboards[packet.scoreName] + if (scoreboard !== undefined && packet.action === 0) { + const updated = scoreboard.add(packet.itemName, packet.value) + bot.emit('scoreUpdated', scoreboard, updated) + } + + if (packet.action === 1) { + if (scoreboard !== undefined) { + const removed = scoreboard.remove(packet.itemName) + return bot.emit('scoreRemoved', scoreboard, removed) + } + + for (const sb of Object.values(scoreboards)) { + if (packet.itemName in sb.itemsMap) { + const removed = sb.remove(packet.itemName) + return bot.emit('scoreRemoved', sb, removed) + } + } + } + }) + + bot._client.on('scoreboard_display_objective', (packet) => { + const { name, position } = packet + const scoreboard = scoreboards[name] + + if (scoreboard !== undefined) { + bot.emit('scoreboardPosition', position, scoreboard, ScoreBoard.positions[position]) + ScoreBoard.positions[position] = scoreboard + } + }) + + bot.scoreboards = scoreboards + bot.scoreboard = ScoreBoard.positions +} diff --git a/bridge/lib/mineflayer/lib/plugins/settings.js b/bridge/lib/mineflayer/lib/plugins/settings.js new file mode 100644 index 0000000..a3bdd7c --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/settings.js @@ -0,0 +1,107 @@ +const assert = require('assert') + +module.exports = inject + +const chatToBits = { + enabled: 0, + commandsOnly: 1, + disabled: 2 +} + +const handToBits = { + left: 0, + right: 1 +} + +const viewDistanceToBits = { + far: 12, + normal: 10, + short: 8, + tiny: 6 +} + +function inject (bot, options) { + function setSettings (settings) { + extend(bot.settings, settings) + + // chat + const chatBits = chatToBits[bot.settings.chat] + assert.ok(chatBits != null, `invalid chat setting: ${bot.settings.chat}`) + + // view distance + let viewDistanceBits = null + if (typeof bot.settings.viewDistance === 'string') { + viewDistanceBits = viewDistanceToBits[bot.settings.viewDistance] + } else if (typeof bot.settings.viewDistance === 'number' && bot.settings.viewDistance > 0) { // Make sure view distance is a valid # || should be 2 or more + viewDistanceBits = bot.settings.viewDistance + } + assert.ok(viewDistanceBits != null, `invalid view distance setting: ${bot.settings.viewDistance}`) + + // hand + const handBits = handToBits[bot.settings.mainHand] + assert.ok(handBits != null, `invalid main hand: ${bot.settings.mainHand}`) + + // skin + // cape is inverted, not used at all (legacy?) + // bot.settings.showCape = !!bot.settings.showCape + const skinParts = bot.settings.skinParts.showCape << 0 | + bot.settings.skinParts.showJacket << 1 | + bot.settings.skinParts.showLeftSleeve << 2 | + bot.settings.skinParts.showRightSleeve << 3 | + bot.settings.skinParts.showLeftPants << 4 | + bot.settings.skinParts.showRightPants << 5 | + bot.settings.skinParts.showHat << 6 + + // write the packet + bot._client.write('settings', { + locale: bot.settings.locale || 'en_US', + viewDistance: viewDistanceBits, + chatFlags: chatBits, + chatColors: bot.settings.colorsEnabled, + skinParts, + mainHand: handBits, + enableTextFiltering: bot.settings.enableTextFiltering, + enableServerListing: bot.settings.enableServerListing + }) + } + + bot.settings = { + chat: options.chat || 'enabled', + colorsEnabled: options.colorsEnabled == null + ? true + : options.colorsEnabled, + viewDistance: options.viewDistance || 'far', + difficulty: options.difficulty == null + ? 2 + : options.difficulty, + skinParts: options.skinParts == null + ? { + showCape: true, + showJacket: true, + showLeftSleeve: true, + showRightSleeve: true, + showLeftPants: true, + showRightPants: true, + showHat: true + } + : options.skinParts, + mainHand: options.mainHand || 'right', + enableTextFiltering: options.enableTextFiltering || false, + enableServerListing: options.enableServerListing || true, + particleStatus: 'all' + } + + bot._client.on('login', () => { + setSettings({}) + }) + + bot.setSettings = setSettings +} + +const hasOwn = {}.hasOwnProperty +function extend (obj, src) { + for (const key in src) { + if (hasOwn.call(src, key)) obj[key] = src[key] + } + return obj +} diff --git a/bridge/lib/mineflayer/lib/plugins/simple_inventory.js b/bridge/lib/mineflayer/lib/plugins/simple_inventory.js new file mode 100644 index 0000000..8c68644 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/simple_inventory.js @@ -0,0 +1,156 @@ +const assert = require('assert') + +module.exports = inject + +const QUICK_BAR_COUNT = 9 +const QUICK_BAR_START = 36 + +function inject (bot) { + let nextQuickBarSlot = 0 + + const armorSlots = { + head: 5, + torso: 6, + legs: 7, + feet: 8 + } + + if (!bot.supportFeature('doesntHaveOffHandSlot')) { + armorSlots['off-hand'] = 45 + } + + async function tossStack (item) { + assert.ok(item) + await bot.clickWindow(item.slot, 0, 0) + await bot.clickWindow(-999, 0, 0) + bot.closeWindow(bot.currentWindow || bot.inventory) + } + + async function toss (itemType, metadata, count) { + const window = bot.currentWindow || bot.inventory + const options = { + window, + itemType, + metadata, + count, + sourceStart: window.inventoryStart, + sourceEnd: window.inventoryEnd, + destStart: -999 + } + await bot.transfer(options) + } + + async function unequip (destination) { + if (destination === 'hand') { + await equipEmpty() + } else { + await disrobe(destination) + } + } + + function setQuickBarSlot (slot) { + assert.ok(slot >= 0) + assert.ok(slot < 9) + if (bot.quickBarSlot === slot) return + bot.quickBarSlot = slot + bot._client.write('held_item_slot', { + slotId: slot + }) + bot.updateHeldItem() + } + + async function equipEmpty () { + for (let i = 0; i < QUICK_BAR_COUNT; ++i) { + if (!bot.inventory.slots[QUICK_BAR_START + i]) { + setQuickBarSlot(i) + return + } + } + const slot = bot.inventory.firstEmptyInventorySlot() + if (!slot) { + await bot.tossStack(bot.heldItem) + return + } + const equipSlot = QUICK_BAR_START + bot.quickBarSlot + await bot.clickWindow(equipSlot, 0, 0) + await bot.clickWindow(slot, 0, 0) + if (bot.inventory.selectedItem) { + await bot.clickWindow(-999, 0, 0) + } + } + + async function disrobe (destination) { + assert.strictEqual(bot.currentWindow, null) + const destSlot = getDestSlot(destination) + await bot.putAway(destSlot) + } + + async function equip (item, destination) { + if (typeof item === 'number') { + item = bot.inventory.findInventoryItem(item) + } + if (item == null || typeof item !== 'object') { + throw new Error('Invalid item object in equip (item is null or typeof item is not object)') + } + if (!destination || destination === null) { + destination = 'hand' + } + const sourceSlot = item.slot + let destSlot = getDestSlot(destination) + + if (sourceSlot === destSlot) { + // don't need to do anything + return + } + + if (destination !== 'hand') { + await bot.moveSlotItem(sourceSlot, destSlot) + return + } + + if (destSlot >= QUICK_BAR_START && destSlot < (QUICK_BAR_START + QUICK_BAR_COUNT) && sourceSlot >= QUICK_BAR_START && sourceSlot < (QUICK_BAR_START + QUICK_BAR_COUNT)) { + // all we have to do is change the quick bar selection + bot.setQuickBarSlot(sourceSlot - QUICK_BAR_START) + return + } + + // find an empty slot on the quick bar to put the source item in + destSlot = bot.inventory.firstEmptySlotRange(QUICK_BAR_START, QUICK_BAR_START + QUICK_BAR_COUNT) + if (destSlot == null) { + // LRU cache for the quick bar items + destSlot = QUICK_BAR_START + nextQuickBarSlot + nextQuickBarSlot = (nextQuickBarSlot + 1) % QUICK_BAR_COUNT + } + setQuickBarSlot(destSlot - QUICK_BAR_START) + await bot.moveSlotItem(sourceSlot, destSlot) + } + + function getDestSlot (destination) { + if (destination === 'hand') { + return QUICK_BAR_START + bot.quickBarSlot + } else { + const destSlot = armorSlots[destination] + assert.ok(destSlot != null, `invalid destination: ${destination}`) + return destSlot + } + } + + function leftMouse (slot) { + return bot.clickWindow(slot, 0, 0) + } + + function rightMouse (slot) { + return bot.clickWindow(slot, 1, 0) + } + + bot.equip = equip + bot.unequip = unequip + bot.toss = toss + bot.tossStack = tossStack + bot.setQuickBarSlot = setQuickBarSlot + bot.getEquipmentDestSlot = getDestSlot + bot.simpleClick = { leftMouse, rightMouse } + + // constants + bot.QUICK_BAR_START = QUICK_BAR_START +} diff --git a/bridge/lib/mineflayer/lib/plugins/sound.js b/bridge/lib/mineflayer/lib/plugins/sound.js new file mode 100644 index 0000000..062e1d1 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/sound.js @@ -0,0 +1,48 @@ +const { Vec3 } = require('vec3') + +module.exports = inject + +function inject (bot) { + bot._client.on('named_sound_effect', (packet) => { + const soundName = packet.soundName + const pt = new Vec3(packet.x / 8, packet.y / 8, packet.z / 8) + const volume = packet.volume + const pitch = packet.pitch + + // In 1.8.8, sound names are in the format "note.harp" or "random.click" + // We need to convert them to the format expected by the test + const normalizedSoundName = bot.supportFeature('playsoundUsesResourceLocation') + ? `minecraft:${soundName.replace(/\./g, '_')}` + : soundName + + // Emit both events for compatibility with tests + bot.emit('soundEffectHeard', normalizedSoundName, pt, volume, pitch) + // Emit hardcodedSoundEffectHeard for compatibility (use 0, 'master' as dummy values) + bot.emit('hardcodedSoundEffectHeard', 0, 'master', pt, volume, pitch) + }) + + bot._client.on('sound_effect', (packet) => { + const soundCategory = packet.soundCategory + const pt = new Vec3(packet.x / 8, packet.y / 8, packet.z / 8) + const volume = packet.volume + const pitch = packet.pitch + + let soundId, soundName + + if (packet.sound) { // ItemSoundHolder + if (packet.sound.data) soundName = packet.sound.data.soundName + else soundId = packet.sound.soundId // Sound specified by ID (registry reference) + } else { // Legacy packet + soundId = packet.soundId + } + + // If we have an ID but no name yet, try to look it up in the registry + soundName ??= bot.registry?.sounds?.[soundId]?.name + + if (soundName) { + bot.emit('soundEffectHeard', soundName, pt, volume, pitch) + } else if (soundId !== null) { + bot.emit('hardcodedSoundEffectHeard', soundId, soundCategory, pt, volume, pitch) + } + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/spawn_point.js b/bridge/lib/mineflayer/lib/plugins/spawn_point.js new file mode 100644 index 0000000..8dac68d --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/spawn_point.js @@ -0,0 +1,11 @@ +const { Vec3 } = require('vec3') + +module.exports = inject + +function inject (bot) { + bot.spawnPoint = new Vec3(0, 0, 0) + bot._client.on('spawn_position', (packet) => { + bot.spawnPoint = new Vec3(packet.location.x, packet.location.y, packet.location.z) + bot.emit('game') + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/tablist.js b/bridge/lib/mineflayer/lib/plugins/tablist.js new file mode 100644 index 0000000..b27b49d --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/tablist.js @@ -0,0 +1,31 @@ +module.exports = inject + +const escapeValueNewlines = str => { + return str.replace(/(": *"(?:\\"|[^"])+")/g, (_, match) => match.replace(/\n/g, '\\n')) +} + +function inject (bot) { + const ChatMessage = require('prismarine-chat')(bot.registry) + + bot.tablist = { + header: new ChatMessage(''), + footer: new ChatMessage('') + } + + bot._client.on('playerlist_header', (packet) => { + if (bot.supportFeature('chatPacketsUseNbtComponents')) { // 1.20.3+ + bot.tablist.header = ChatMessage.fromNotch(packet.header) + bot.tablist.footer = ChatMessage.fromNotch(packet.footer) + } else { + if (packet.header) { + const header = escapeValueNewlines(packet.header) + bot.tablist.header = ChatMessage.fromNotch(header) + } + + if (packet.footer) { + const footer = escapeValueNewlines(packet.footer) + bot.tablist.footer = ChatMessage.fromNotch(footer) + } + } + }) +} diff --git a/bridge/lib/mineflayer/lib/plugins/team.js b/bridge/lib/mineflayer/lib/plugins/team.js new file mode 100644 index 0000000..b6a159c --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/team.js @@ -0,0 +1,90 @@ +module.exports = inject + +// TODO: apply this to all versions and rename scoreboard_team -> teams in minecraft-data +const TEAM_MODES = ['add', 'remove', 'change', 'join', 'leave'] + +function inject (bot) { + const Team = require('../team')(bot.registry) + const teams = {} + + function teamHandler (packet) { + const { team: teamName, players = [] } = packet + const mode = typeof packet.mode === 'number' ? TEAM_MODES[packet.mode] : packet.mode + + let team = teams[teamName] + + switch (mode) { + case 'add': + team = new Team( + teamName, + packet.name, + packet.friendlyFire, + packet.nameTagVisibility, + packet.collisionRule, + packet.formatting, + packet.prefix, + packet.suffix + ) + for (const player of players) { + team.add(player) + bot.teamMap[player] = team + } + teams[teamName] = team + bot.emit('teamCreated', teams[teamName]) + break + + case 'remove': + if (!team) break + team.members.forEach((member) => { + delete bot.teamMap[member] + }) + delete teams[teamName] + bot.emit('teamRemoved', teams[teamName]) + break + + case 'change': + if (!team) break + team.update( + packet.name, + packet.friendlyFire, + packet.nameTagVisibility, + packet.collisionRule, + packet.formatting, + packet.prefix, + packet.suffix + ) + bot.emit('teamUpdated', teams[teamName]) + break + + case 'join': + if (!team) break + for (const player of players) { + team.add(player) + bot.teamMap[player] = team + } + bot.emit('teamMemberAdded', teams[teamName]) + break + + case 'leave': + if (!team) break + for (const player of players) { + team.remove(player) + delete bot.teamMap[player] + } + bot.emit('teamMemberRemoved', teams[teamName]) + break + + default: + bot._warn(`Unknown team mode handling team update: ${mode}`) + } + } + + if (bot.supportFeature('teamUsesScoreboard')) { + bot._client.on('scoreboard_team', teamHandler) + } else { + bot._client.on('teams', teamHandler) + } + + bot.teams = teams + bot.teamMap = {} +} diff --git a/bridge/lib/mineflayer/lib/plugins/time.js b/bridge/lib/mineflayer/lib/plugins/time.js new file mode 100644 index 0000000..0b9411d --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/time.js @@ -0,0 +1,38 @@ +module.exports = inject + +function inject (bot) { + bot.time = { + doDaylightCycle: null, + bigTime: null, + time: null, + timeOfDay: null, + day: null, + isDay: null, + moonPhase: null, + bigAge: null, + age: null + } + bot._client.on('update_time', (packet) => { + const time = longToBigInt(packet.time) + const age = longToBigInt(packet.age) + const doDaylightCycle = packet.tickDayTime !== undefined ? !!packet.tickDayTime : time >= 0n + // When doDaylightCycle is false, we need to take the absolute value of time + const finalTime = doDaylightCycle ? time : (time < 0n ? -time : time) + + bot.time.doDaylightCycle = doDaylightCycle + bot.time.bigTime = finalTime + bot.time.time = Number(finalTime) + bot.time.timeOfDay = bot.time.time % 24000 + bot.time.day = Math.floor(bot.time.time / 24000) + bot.time.isDay = bot.time.timeOfDay >= 0 && bot.time.timeOfDay < 13000 + bot.time.moonPhase = bot.time.day % 8 + bot.time.bigAge = age + bot.time.age = Number(age) + + bot.emit('time') + }) +} + +function longToBigInt (arr) { + return BigInt.asIntN(64, (BigInt(arr[0]) << 32n)) | BigInt(arr[1]) +} diff --git a/bridge/lib/mineflayer/lib/plugins/title.js b/bridge/lib/mineflayer/lib/plugins/title.js new file mode 100644 index 0000000..4e43ac1 --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/title.js @@ -0,0 +1,37 @@ +module.exports = inject +function inject (bot) { + function parseTitle (text) { + try { + const parsed = JSON.parse(text) + return typeof parsed === 'string' ? parsed : (parsed.text || text) + } catch { + return typeof text === 'string' ? text.replace(/^"|"$/g, '') : text + } + } + + if (bot.supportFeature('titleUsesLegacyPackets')) { + bot._client.on('title', (packet) => { + if (packet.action === 0) bot.emit('title', parseTitle(packet.text), 'title') + else if (packet.action === 1) bot.emit('title', parseTitle(packet.text), 'subtitle') + else if (packet.action === 2) bot.emit('title_times', packet.fadeIn, packet.stay, packet.fadeOut) + else if (packet.action === 3) { + if (packet.fadeIn !== undefined) bot.emit('title_times', packet.fadeIn, packet.stay, packet.fadeOut) + else bot.emit('title_clear') + } else if (packet.action === 4) bot.emit('title_clear') + }) + } else if (bot.supportFeature('titleUsesNewPackets')) { + function getText (packet) { + let text = packet.text + if (typeof text === 'object' && text.value !== undefined) text = text.value + return parseTitle(text) + } + bot._client.on('set_title_text', (packet) => bot.emit('title', getText(packet), 'title')) + bot._client.on('set_title_subtitle', (packet) => bot.emit('title', getText(packet), 'subtitle')) + bot._client.on('set_title_time', (packet) => { + if (typeof packet.fadeIn === 'number' && typeof packet.stay === 'number' && typeof packet.fadeOut === 'number') { + bot.emit('title_times', packet.fadeIn, packet.stay, packet.fadeOut) + } + }) + bot._client.on('clear_titles', () => bot.emit('title_clear')) + } +} diff --git a/bridge/lib/mineflayer/lib/plugins/villager.js b/bridge/lib/mineflayer/lib/plugins/villager.js new file mode 100644 index 0000000..e20e34a --- /dev/null +++ b/bridge/lib/mineflayer/lib/plugins/villager.js @@ -0,0 +1,240 @@ +const assert = require('assert') +const { once } = require('../promise_utils') + +module.exports = inject + +function inject (bot, { version }) { + const { entitiesByName } = bot.registry + const Item = require('prismarine-item')(bot.registry) + + let selectTrade + if (bot.supportFeature('useMCTrSel')) { + bot._client.registerChannel('MC|TrSel', 'i32') + selectTrade = (choice) => { + bot._client.writeChannel('MC|TrSel', choice) + } + } else { + selectTrade = (choice) => { + bot._client.write('select_trade', { slot: choice }) + } + } + + const tradeListSchema = [ + 'container', + [ + { type: 'i32', name: 'windowId' }, + { + name: 'trades', + type: [ + 'array', + { + countType: 'i8', + type: [ + 'container', + [ + { type: 'slot', name: 'inputItem1' }, + { type: 'slot', name: 'outputItem' }, + { type: 'bool', name: 'hasItem2' }, + { + name: 'inputItem2', + type: [ + 'switch', + { + compareTo: 'hasItem2', + fields: { + true: 'slot', + false: 'void' + } + } + ] + }, + { type: 'bool', name: 'tradeDisabled' }, + { type: 'i32', name: 'nbTradeUses' }, + { type: 'i32', name: 'maximumNbTradeUses' } + ] + ] + }] + } + ] + ] + + let tradeListPacket + if (bot.supportFeature('useMCTrList')) { + tradeListPacket = 'MC|TrList' + bot._client.registerChannel('MC|TrList', tradeListSchema) + } else if (bot.supportFeature('usetraderlist')) { + tradeListPacket = 'minecraft:trader_list' + bot._client.registerChannel('minecraft:trader_list', tradeListSchema) + } else { + tradeListPacket = 'trade_list' + } + + async function openVillager (villagerEntity) { + const villagerType = entitiesByName.villager ? entitiesByName.villager.id : entitiesByName.Villager.id + assert.strictEqual(villagerEntity.entityType, villagerType) + let ready = false + + const villagerPromise = bot.openEntity(villagerEntity) + bot._client.on(tradeListPacket, gotTrades) + const villager = await villagerPromise + if (villager.type !== 'minecraft:villager' && villager.type !== 'minecraft:merchant') { + throw new Error('Expected minecraft:villager or minecraft:mechant type, but got ' + villager.type) + } + + villager.trades = null + villager.selectedTrade = null + + villager.once('close', () => { + bot._client.removeListener(tradeListPacket, gotTrades) + }) + + villager.trade = async (index, count) => { + await bot.trade(villager, index, count) + } + + if (!ready) await once(villager, 'ready') + return villager + + async function gotTrades (packet) { + const villager = await villagerPromise + if (packet.windowId !== villager.id) return + assert.ok(packet.trades) + villager.trades = packet.trades.map(trade => { + trade.inputs = [trade.inputItem1 = Item.fromNotch(trade.inputItem1 || { blockId: -1 })] + if (trade.inputItem2?.itemCount != null) { + trade.inputs.push(trade.inputItem2 = Item.fromNotch(trade.inputItem2 || { blockId: -1 })) + } + + trade.hasItem2 = !!(trade.inputItem2 && trade.inputItem2.type && trade.inputItem2.count) + trade.outputs = [trade.outputItem = Item.fromNotch(trade.outputItem || { blockId: -1 })] + + if (trade.demand !== undefined && trade.specialPrice !== undefined) { // the price is affected by demand and reputation + const demandDiff = Math.max(0, Math.floor(trade.inputItem1.count * trade.demand * trade.priceMultiplier)) + trade.realPrice = Math.min(Math.max((trade.inputItem1.count + trade.specialPrice + demandDiff), 1), trade.inputItem1.stackSize) + } else { + trade.realPrice = trade.inputItem1.count + } + return trade + }) + if (!ready) { + ready = true + villager.emit('ready') + } + } + } + + async function trade (villager, index, count) { + const choice = parseInt(index, 10) // allow string argument + assert.notStrictEqual(villager.trades, null) + assert.notStrictEqual(villager.trades[choice], null) + const Trade = villager.trades[choice] + villager.selectedTrade = Trade + count = count || Trade.maximumNbTradeUses - Trade.nbTradeUses + assert.ok(Trade.maximumNbTradeUses - Trade.nbTradeUses > 0, 'trade blocked') + assert.ok(Trade.maximumNbTradeUses - Trade.nbTradeUses >= count) + + const itemCount1 = villager.count(Trade.inputItem1.type, Trade.inputItem1.metadata) + const hasEnoughItem1 = itemCount1 >= Trade.realPrice * count + let hasEnoughItem2 = true + let itemCount2 = 0 + if (Trade.hasItem2) { + itemCount2 = villager.count(Trade.inputItem2.type, Trade.inputItem2.metadata) + hasEnoughItem2 = itemCount2 >= Trade.inputItem2.count * count + } + if (!hasEnoughItem1) { + throw new Error('Not enough item 1 to trade') + } + if (!hasEnoughItem2) { + throw new Error('Not enough item 2 to trade') + } + + selectTrade(choice) + if (bot.supportFeature('selectingTradeMovesItems')) { // 1.14+ the server moves items around by itself after selecting a trade + const proms = [] + proms.push(once(villager, 'updateSlot:0')) + if (Trade.hasItem2) proms.push(once(villager, 'updateSlot:1')) + if (bot.supportFeature('setSlotAsTransaction')) { + proms.push(once(villager, 'updateSlot:2')) + } + await Promise.all(proms) + } + + for (let i = 0; i < count; i++) { + await putRequirements(villager, Trade) + // ToDo: See if this does anything kappa + Trade.nbTradeUses++ + if (Trade.maximumNbTradeUses - Trade.nbTradeUses === 0) { + Trade.tradeDisabled = true + } + if (!bot.supportFeature('setSlotAsTransaction')) { + villager.updateSlot(2, Object.assign({}, Trade.outputItem)) + + const [slot1, slot2] = villager.slots + if (slot1) { + assert.strictEqual(slot1.type, Trade.inputItem1.type) + const updatedCount1 = slot1.count - Trade.realPrice + const updatedSlot1 = updatedCount1 <= 0 + ? null + : { ...slot1, count: updatedCount1 } + villager.updateSlot(0, updatedSlot1) + } + + if (slot2) { + assert.strictEqual(slot2.type, Trade.inputItem2.type) + const updatedCount2 = slot2.count - Trade.inputItem2.count + const updatedSlot2 = updatedCount2 <= 0 + ? null + : { ...slot2, count: updatedCount2 } + villager.updateSlot(1, updatedSlot2) + } + } + + await bot.putAway(2) + } + + for (const i of [0, 1]) { + if (villager.slots[i]) { + await bot.putAway(i) // 1.14+ whole stacks of items will automatically be placed , so there might be some left over + } + } + } + + async function putRequirements (window, Trade) { + const [slot1, slot2] = window.slots + const { type: type1, metadata: metadata1 } = Trade.inputItem1 + + const input1 = slot1 + ? Math.max(0, Trade.realPrice - slot1.count) + : Trade.realPrice + if (input1) { + await deposit(window, type1, metadata1, input1, 0) + } + if (Trade.hasItem2) { + const { count: tradeCount2, type: type2, metadata: metadata2 } = Trade.inputItem2 + + const input2 = slot2 + ? Math.max(0, tradeCount2 - slot2.count) + : tradeCount2 + if (input2) { + await deposit(window, type2, metadata2, input2, 1) + } + } + } + + async function deposit (window, itemType, metadata, count, slot) { + const options = { + window, + itemType, + metadata, + count, + sourceStart: window.inventoryStart, + sourceEnd: window.inventoryEnd, + destStart: slot, + destEnd: slot + 1 + } + await bot.transfer(options) + } + + bot.openVillager = openVillager + bot.trade = trade +} diff --git a/bridge/lib/mineflayer/lib/promise_utils.js b/bridge/lib/mineflayer/lib/promise_utils.js new file mode 100644 index 0000000..a2f2873 --- /dev/null +++ b/bridge/lib/mineflayer/lib/promise_utils.js @@ -0,0 +1,95 @@ +function sleep (ms) { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +function createTask () { + const task = { + done: false + } + task.promise = new Promise((resolve, reject) => { + task.cancel = (err) => { + if (!task.done) { + task.done = true + reject(err) + } + } + task.finish = (result) => { + if (!task.done) { + task.done = true + resolve(result) + } + } + }) + return task +} + +function createDoneTask () { + const task = { + done: true, + promise: Promise.resolve(), + cancel: () => {}, + finish: () => {} + } + return task +} + +/** + * Similar to the 'once' function from the 'events' module, but allows you to add a condition for when you want to + * actually handle the event, as well as a timeout. The listener is additionally removed if a timeout occurs, instead + * of with 'once' where a listener might stay forever if it never triggers. + * Note that timeout and checkCondition, both optional, are in the third parameter as an object. + * @param emitter - The event emitter to listen to + * @param event - The name of the event you want to listen for + * @param [timeout=0] - An amount, in milliseconds, for which to wait before considering the promise failed. <0 = none. + * @param [checkCondition] - A function which matches the same signature of an event emitter handler, and should return something truthy if you want the event to be handled. If this is not provided, all events are handled. + * @returns {Promise} A promise which will either resolve to an *array* of values in the handled event, or will reject on timeout if applicable. This may never resolve if no timeout is set and the event does not fire. + */ +function onceWithCleanup (emitter, event, { timeout = 0, checkCondition = undefined } = {}) { + const task = createTask() + + const onEvent = (...data) => { + if (typeof checkCondition === 'function' && !checkCondition(...data)) { + return + } + + task.finish(data) + } + + emitter.addListener(event, onEvent) + + if (typeof timeout === 'number' && timeout > 0) { + // For some reason, the call stack gets lost if we don't create the error outside of the .then call + const timeoutError = new Error(`Event ${event} did not fire within timeout of ${timeout}ms`) + sleep(timeout).then(() => { + if (!task.done) { + task.cancel(timeoutError) + } + }) + } + + task.promise.catch(() => {}).finally(() => emitter.removeListener(event, onEvent)) + + return task.promise +} + +function once (emitter, event, timeout = 20000) { + return onceWithCleanup(emitter, event, { timeout }) +} + +function withTimeout (promise, timeout) { + return Promise.race([ + promise, + sleep(timeout).then(() => { + throw new Error('Promise timed out.') + }) + ]) +} + +module.exports = { + once, + sleep, + createTask, + createDoneTask, + onceWithCleanup, + withTimeout +} diff --git a/bridge/lib/mineflayer/lib/scoreboard.js b/bridge/lib/mineflayer/lib/scoreboard.js new file mode 100644 index 0000000..cb55990 --- /dev/null +++ b/bridge/lib/mineflayer/lib/scoreboard.js @@ -0,0 +1,65 @@ +const sortItems = (a, b) => { + if (a.value > b.value) return -1 + if (a.value < b.value) return 1 + return 1 +} + +module.exports = (bot) => { + const ChatMessage = require('prismarine-chat')(bot.registry) + + class ScoreBoard { + constructor (packet) { + this.name = packet.name + this.setTitle(packet.displayText) + this.itemsMap = {} + } + + setTitle (title) { + try { + this.title = JSON.parse(title).text // version>1.13 + } catch { + this.title = title + } + } + + add (name, value) { + this.itemsMap[name] = { name, value } + this.itemsMap[name] = { + name, + value, + get displayName () { + if (name in bot.teamMap) { + return bot.teamMap[name].displayName(name) + } + return new ChatMessage(name) + } + } + return this.itemsMap[name] + } + + remove (name) { + const removed = this.itemsMap[name] + delete this.itemsMap[name] + return removed + } + + get items () { + return Object.values(this.itemsMap).sort(sortItems) + } + } + + ScoreBoard.positions = { + get list () { + return this[0] + }, + + get sidebar () { + return this[1] + }, + + get belowName () { + return this[2] + } + } + return ScoreBoard +} diff --git a/bridge/lib/mineflayer/lib/team.js b/bridge/lib/mineflayer/lib/team.js new file mode 100644 index 0000000..080c00c --- /dev/null +++ b/bridge/lib/mineflayer/lib/team.js @@ -0,0 +1,86 @@ +function colorString (color) { + const formatting = [ + 'black', + 'dark_blue', + 'dark_green', + 'dark_aqua', + 'dark_red', + 'dark_purple', + 'gold', + 'gray', + 'dark_gray', + 'blue', + 'green', + 'aqua', + 'red', + 'light_purple', + 'yellow', + 'white', + 'obfuscated', + 'bold', + 'strikethrough', + 'underlined', + 'italic', + 'reset' + ] + if (color === undefined || color > 21 || color === -1) return 'reset' + return formatting[color] +} + +function loader (registry) { + const ChatMessage = require('prismarine-chat')(registry) + const MessageBuilder = ChatMessage.MessageBuilder + return class Team { + constructor (team, name, friendlyFire, nameTagVisibility, collisionRule, formatting, prefix, suffix) { + this.team = team + this.update(name, friendlyFire, nameTagVisibility, collisionRule, formatting, prefix, suffix) + this.membersMap = {} + } + + parseMessage (value) { + if (registry.supportFeature('teamUsesChatComponents')) { // 1.13+ + return ChatMessage.fromNotch(value) + } else { + const result = MessageBuilder.fromString(value, { colorSeparator: '§' }) + if (result === null) { + return new ChatMessage('') + } + return new ChatMessage(result.toJSON()) + } + } + + add (name) { + this.membersMap[name] = '' + return this.membersMap[name] + } + + remove (name) { + const removed = this.membersMap[name] + delete this.membersMap[name] + return removed + } + + update (name, friendlyFire, nameTagVisibility, collisionRule, formatting, prefix, suffix) { + this.name = this.parseMessage(name) + this.friendlyFire = friendlyFire + this.nameTagVisibility = nameTagVisibility + this.collisionRule = collisionRule + this.color = colorString(formatting) + this.prefix = this.parseMessage(prefix) + this.suffix = this.parseMessage(suffix) + } + + // Return a chat component with prefix + color + name + suffix + displayName (member) { + const name = this.prefix.clone() + name.append(new ChatMessage({ text: member, color: this.color }), this.suffix) + return name + } + + get members () { + return Object.keys(this.membersMap) + } + } +} + +module.exports = loader diff --git a/bridge/lib/mineflayer/lib/version.js b/bridge/lib/mineflayer/lib/version.js new file mode 100644 index 0000000..0b316ee --- /dev/null +++ b/bridge/lib/mineflayer/lib/version.js @@ -0,0 +1,18 @@ +const testedVersions = ['1.8.8', '1.9.4', '1.10.2', '1.11.2', '1.12.2', '1.13.2', '1.14.4', '1.15.2', '1.16.5', '1.17.1', '1.18.2', '1.19', '1.19.2', '1.19.3', '1.19.4', '1.20.1', '1.20.2', '1.20.4', '1.20.6', '1.21.1', '1.21.3', '1.21.4', '1.21.5', '1.21.6', '1.21.8'] +const bedrockTestedVersions = ['1.21.130', '26.10'] + +module.exports = (isBedrock) => { + if (isBedrock) { + return { + testedVersions: bedrockTestedVersions, + latestSupportedVersion: bedrockTestedVersions[bedrockTestedVersions.length - 1], + oldestSupportedVersion: bedrockTestedVersions[0] + }; + } else { + return { + testedVersions: testedVersions, + latestSupportedVersion: testedVersions[testedVersions.length - 1], + oldestSupportedVersion: testedVersions[0] + }; + } +}; diff --git a/bridge/lib/mineflayer/package.json b/bridge/lib/mineflayer/package.json new file mode 100644 index 0000000..880520d --- /dev/null +++ b/bridge/lib/mineflayer/package.json @@ -0,0 +1,58 @@ +{ + "name": "mineflayer", + "version": "4.33.0", + "description": "create minecraft bots with a stable, high level API", + "main": "index.js", + "types": "index.d.ts", + "scripts": { + "mocha_test": "mocha --reporter spec --exit", + "test": "npm run mocha_test", + "pretest": "npm run lint", + "lint": "standard && standard-markdown", + "fix": "standard --fix && standard-markdown --fix", + "prepublishOnly": "cp docs/README.md README.md" + }, + "repository": { + "type": "git", + "url": "git://github.com/PrismarineJS/mineflayer.git" + }, + "engines": { + "node": ">=22" + }, + "license": "MIT", + "dependencies": { + "bedrock-protocol": "^3.52.0", + "bedrock-provider": "^3.0.0", + "expect": "^29.7.0", + "minecraft-data": "^3.98.0", + "minecraft-protocol": "^1.61.0", + "prismarine-biome": "^1.1.1", + "prismarine-block": "^1.22.0", + "prismarine-chat": "^1.7.1", + "prismarine-chunk": "^1.39.0", + "prismarine-entity": "^2.5.0", + "prismarine-item": "^1.17.0", + "prismarine-nbt": "^2.0.0", + "prismarine-physics": "^1.9.0", + "prismarine-recipe": "^1.3.0", + "prismarine-registry": "^1.10.0", + "prismarine-windows": "^2.9.0", + "prismarine-world": "^3.6.0", + "protodef": "^1.18.0", + "typed-emitter": "^1.0.0", + "uuid-1345": "^1.0.2", + "vec3": "^0.1.7" + }, + "devDependencies": { + "@types/mocha": "^10.0.10", + "@types/node": "^24.0.6", + "doctoc": "^2.0.1", + "minecraft-wrap": "^1.3.0", + "mineflayer": "file:", + "mocha": "^11.0.1", + "protodef-yaml": "^1.5.3", + "standard": "^17.0.0", + "standard-markdown": "^7.1.0", + "typescript": "^5.4.5" + } +} diff --git a/bridge/lib/mineflayer/tsconfig.json b/bridge/lib/mineflayer/tsconfig.json new file mode 100644 index 0000000..e590ea3 --- /dev/null +++ b/bridge/lib/mineflayer/tsconfig.json @@ -0,0 +1,10 @@ +{ + "compilerOptions": { + "noEmit": true, // Optional - see note below + "target": "esnext", + "module": "nodenext", + "rewriteRelativeImportExtensions": true, + "erasableSyntaxOnly": true, + "verbatimModuleSyntax": true + } +} \ No newline at end of file diff --git a/bridge/lib/prismarine-chunk/.github/dependabot.yml b/bridge/lib/prismarine-chunk/.github/dependabot.yml new file mode 100644 index 0000000..77b6cbf --- /dev/null +++ b/bridge/lib/prismarine-chunk/.github/dependabot.yml @@ -0,0 +1,11 @@ +version: 2 +updates: +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 + ignore: + - dependency-name: "@types/node" + versions: + - 15.0.0 diff --git a/bridge/lib/prismarine-chunk/.github/workflows/ci.yml b/bridge/lib/prismarine-chunk/.github/workflows/ci.yml new file mode 100644 index 0000000..ec6441f --- /dev/null +++ b/bridge/lib/prismarine-chunk/.github/workflows/ci.yml @@ -0,0 +1,25 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [22.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - run: npm install + - run: npm test diff --git a/bridge/lib/prismarine-chunk/.github/workflows/commands.yml b/bridge/lib/prismarine-chunk/.github/workflows/commands.yml new file mode 100644 index 0000000..d2286e2 --- /dev/null +++ b/bridge/lib/prismarine-chunk/.github/workflows/commands.yml @@ -0,0 +1,22 @@ +name: Repo Commands + +on: + issue_comment: # Handle comment commands + types: [created] + pull_request: # Handle renamed PRs + types: [edited] + +jobs: + comment-trigger: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - name: Run command handlers + uses: PrismarineJS/prismarine-repo-actions@master + with: + # NOTE: You must specify a Personal Access Token (PAT) with repo access here. While you can use the default GITHUB_TOKEN, actions taken with it will not trigger other actions, so if you have a CI workflow, commits created by this action will not trigger it. + token: ${{ secrets.PAT_PASSWORD }} + # See `Options` section below for more info on these options + install-command: npm install + /fixlint.fix-command: npm run fix \ No newline at end of file diff --git a/bridge/lib/prismarine-chunk/.github/workflows/npm-publish.yml b/bridge/lib/prismarine-chunk/.github/workflows/npm-publish.yml new file mode 100644 index 0000000..95e11fb --- /dev/null +++ b/bridge/lib/prismarine-chunk/.github/workflows/npm-publish.yml @@ -0,0 +1,32 @@ +name: npm-publish +on: + push: + branches: + - master # Change this to your default branch +jobs: + npm-publish: + name: npm-publish + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@master + - name: Set up Node.js + uses: actions/setup-node@master + with: + node-version: 22.0.0 + - id: publish + uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_AUTH_TOKEN }} + - name: Create Release + if: steps.publish.outputs.type != 'none' + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.publish.outputs.version }} + release_name: Release ${{ steps.publish.outputs.version }} + body: ${{ steps.publish.outputs.version }} + draft: false + prerelease: false diff --git a/bridge/lib/prismarine-chunk/.gitignore b/bridge/lib/prismarine-chunk/.gitignore new file mode 100644 index 0000000..276fe57 --- /dev/null +++ b/bridge/lib/prismarine-chunk/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +benchmarks/results/ +package-lock.json +.vscode +.DS_Store +tools/bedrock_servers/ diff --git a/bridge/lib/prismarine-chunk/.gitpod.yml b/bridge/lib/prismarine-chunk/.gitpod.yml new file mode 100644 index 0000000..38fc373 --- /dev/null +++ b/bridge/lib/prismarine-chunk/.gitpod.yml @@ -0,0 +1,2 @@ +tasks: +- command: npm install diff --git a/bridge/lib/prismarine-chunk/.npmignore b/bridge/lib/prismarine-chunk/.npmignore new file mode 100644 index 0000000..20da7ef --- /dev/null +++ b/bridge/lib/prismarine-chunk/.npmignore @@ -0,0 +1,2 @@ +test/ +tools/bedrock_servers/ \ No newline at end of file diff --git a/bridge/lib/prismarine-chunk/.npmrc b/bridge/lib/prismarine-chunk/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/bridge/lib/prismarine-chunk/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/bridge/lib/prismarine-chunk/HISTORY.md b/bridge/lib/prismarine-chunk/HISTORY.md new file mode 100644 index 0000000..9039f4d --- /dev/null +++ b/bridge/lib/prismarine-chunk/HISTORY.md @@ -0,0 +1,260 @@ +## History + +### 1.39.0 +* [1.21.5 continued (#295)](https://github.com/PrismarineJS/prismarine-chunk/commit/3ecb645d103f7105211356029e9b1a58b7c7ea98) (thanks @rom1504) +* [Bump expect from 29.7.0 to 30.0.5 (#297)](https://github.com/PrismarineJS/prismarine-chunk/commit/15c6bc772615c922882a319c2e367d9fddb0dbbe) (thanks @dependabot[bot]) +* [node 22 (#273)](https://github.com/PrismarineJS/prismarine-chunk/commit/43d54461520ab09fe1aebd613f45160389ef40ca) (thanks @rom1504) + +### 1.38.1 +* [Fix pc1.18 light index (#271)](https://github.com/PrismarineJS/prismarine-chunk/commit/9a9673b8a7e4b4810dd128febf25467bf2b99b85) (thanks @extremeheat) + +### 1.38.0 +* [Support for hashed block network ID's (#237)](https://github.com/PrismarineJS/prismarine-chunk/commit/f1c1f9a7d4cf977ce46975503f8f92db7836e8c9) (thanks @FreezeEngine) + +### 1.37.0 +* [Bump mocha from 10.8.2 to 11.0.1 (#266)](https://github.com/PrismarineJS/prismarine-chunk/commit/ccd97e55d4e60db9f70c093e507ff69598eb3f43) (thanks @dependabot[bot]) +* [pc1.18+: Fix world height defaults and light masks (#267)](https://github.com/PrismarineJS/prismarine-chunk/commit/92ae1d347299a9925a23d97014ece07c98627fd0) (thanks @extremeheat) + +### 1.36.0 +* [1.21 (#263)](https://github.com/PrismarineJS/prismarine-chunk/commit/381497b893857a0c60836873f3c26f8efc52f29b) (thanks @Madlykeanu) +* [Bump @types/node from 20.16.11 to 22.7.5 (#262)](https://github.com/PrismarineJS/prismarine-chunk/commit/4cc2384fb49aba138644f2ef5b35cef07e56f6eb) (thanks @dependabot[bot]) +* [Update index.d.ts](https://github.com/PrismarineJS/prismarine-chunk/commit/2dc9a9d467810c906b132595567b05d8f58b7bed) (thanks @extremeheat) +* [Minor fix loadParsedLights type (#239)](https://github.com/PrismarineJS/prismarine-chunk/commit/18d786e31ab3f4f7c3f61058e08e9e421feecfd2) (thanks @zardoy) +* [fix: toJson / fromJson didn't use maxBitsPerBlock (#238)](https://github.com/PrismarineJS/prismarine-chunk/commit/eb39a905761a36f733a456110e6b49d655bf5c16) (thanks @zardoy) +* [use features in tests (#233)](https://github.com/PrismarineJS/prismarine-chunk/commit/0631db23c79f63d0bd37cb2764a9d6364c95688c) (thanks @extremeheat) + +### 1.35.0 +* [Fix direct palettes (#232)](https://github.com/PrismarineJS/prismarine-chunk/commit/6422abc93ac121f635a03ffa339feb6e5b7b37bf) (thanks @frej4189) +* [Fixes issue https://github.com/PrismarineJS/prismarine-chunk/issues/229 (#231)](https://github.com/PrismarineJS/prismarine-chunk/commit/bed78672ba07cb6d82ee66d148f8ce0ae1ce83c2) (thanks @Flonja) +* [Add command gh workflow allowing to use release command in comments (#228)](https://github.com/PrismarineJS/prismarine-chunk/commit/0ac8714df114707d8f9c1d92fc0a7b5dcfcce619) (thanks @rom1504) + +## 1.34.0 + +* 1.20 support + +## 1.33.1 +* Fix attempting to set skylight in chunks with no skylight + +## 1.33.0 +* Bedrock 1.19.1 support, fix pc 1.18 world height from disk + +## 1.32.0 + +* 1.19 support + +## 1.31.0 + +* update mcdata + +## 1.30.0 + +* Bedrock 1.16 - 1.18.0 chunks (@extremeheat) +* Block sections are not biomes (@nickelpro) + +## 1.29.0 + +* Implement prismarine-registry, basic block entities and 1.18 disk loading + +## 1.28.1 + +* improve the palette hack for 1.18 + +## 1.28.0 + +* expose palette in 1.18 + +## 1.27.0 + +* Add 1.18 chunk support (@nickelpro) + +## 1.26.0 + +* Fix fromLongArray index bug (@nickelpro) +* Fix bitArray Or bug (@nickelpro) +* Correctly update empty light sections (@nickelpro) +* Be more correct about updating light masks (@nickelpro) +* Add BitArray.or test (@nickelpro) + +## 1.25.0 + +* Add type info and bounds checks (@nickelpro) +* Fix skyLightMask bookkeeping (@nickelpro) +* Set GLOBAL_BITS_PER_BLOCK to 16 (@nickelpro) +* Expose ChunkSections (@extremeheat) + +## 1.24.0 + +* 1.17 support (thanks @nickelpro @Archengius @u9g) + +## 1.23.0 + +* Add toArray/fromArray to BitArrays (@Karang) +* Use Uint32Array instead of Array (@Saiv46) +* add version property to chunk object (@u9g) +* Fix pe -> bedrock (@nickelpro) + +## 1.22.0 + +* optimize for browser by inlining getSectionIndex and removing asserts (@rom1504) + +## 1.21.0 + +* fix initialize in all versions but 1.8 (@rom1504) +* add typescript typings (@Darkflame72) + +## 1.20.3 + +* Several bug fix (thanks @IdanHo) + +## 1.20.2 + +* Discard the 0 length of the missing palette array in 1.9 (thanks @IdanHo) + +## 1.20.1 + +* Return air when reading y < 0 or y >= 256 + +## 1.20.0 + +* 1.16 support + +## 1.19.0 + +* setBlockData for 1.13, 1.14, 1.15 (thanks @Deudly) + +## 1.18.1 + +* fix bitwise unsigned operators => fix dumping chunks for 1.9->1.12 + +## 1.18.0 + +* reimplement 1.9->1.12 in a similar way to 1.13 (remove protodef dependency) +* implement full chunk for 1.8 +* add empty load and dump biomes and light methods for simplicity in all versions + +## 1.17.0 + +* support for full chunk property (thanks @Karang) +* fix bug in json serialization + +## 1.16.0 + +* support for 1.15 chunk (thanks @Karang) + +## 1.15.0 + +* support for 1.14 chunk (thanks @Karang) + +## 1.14.0 + +* faster 1.13 chunk implementation (thanks @Karang) + +## 1.13.0 + +* fast json serialization/parsing for out of process loading/writing + +## 1.12.0 + +* 1.13 support (thanks @hornta) + +## 1.11.1 + +* fix dumping for noSkylight chunks for 1.9-1.12 (thanks @IdanHo) + +## 1.11.0 + +* add chunk handling for chunks without skylight data in 1.8 (thanks @skullteria) + +## 1.10.0 + +* support 1.13 +* better tests + +## 1.9.1 + +* standardjs +* circleci 2 +* better no chunk implementation exception + +## 1.9.0 + +* small 1.9 fix (thanks @Flynnn) +* handle skylightsent in 1.8 + +## 1.8.2 + +* fix initialize in 1.8 + test + +### 1.8.1 + +* fix initialize in 1.8 + +### 1.8.0 + +* optimization of 1.9 chunk done by @allain + +### 1.7.0 + +* supports mcpc 1.12 (same as 1.9) + +### 1.6.0 + +* add skyLightSent to load + +### 1.5.1 + +* use last protodef, fix longToByte (no countTypeArgs), and remove gulp + +### 1.5.0 + +* supports mcpc 1.10 and 1.11 (same as 1.9) + +### 1.4.0 + +* supports mcpc 1.9 (thanks @Flynnn) + +### 1.3.0 + +* supports bitmap in load and dump in 1.8, default to bitmap == 0xFFFF + +### 1.2.0 + +* support MCPE 1.0 chunks + +### 1.1.0 + +* support MCPE 0.14 chunks + +### 1.0.1 + +* update to babel6 + +### 1.0.0 + +* bump dependencies + +### 0.3.2 + +* simplify and fix initialize + +### 0.3.1 + +* fix iniPos in initialize + +### 0.3.0 + +* add Chunk.initialize, useful for fast generation + +### 0.2.1 + + * fix the badge + +### 0.2.0 + + * use vec3 + * add an example + doc + * use prismarine-block + +### 0.1.0 + +* First version, basic functionality diff --git a/bridge/lib/prismarine-chunk/LICENSE b/bridge/lib/prismarine-chunk/LICENSE new file mode 100644 index 0000000..4c4bee5 --- /dev/null +++ b/bridge/lib/prismarine-chunk/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2021 PrismarineJS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bridge/lib/prismarine-chunk/README.md b/bridge/lib/prismarine-chunk/README.md new file mode 100644 index 0000000..fe363e8 --- /dev/null +++ b/bridge/lib/prismarine-chunk/README.md @@ -0,0 +1,260 @@ +# prismarine-chunk + +[![NPM version](https://img.shields.io/npm/v/prismarine-chunk.svg)](http://npmjs.com/package/prismarine-chunk) +[![Build Status](https://github.com/PrismarineJS/prismarine-chunk/workflows/CI/badge.svg)](https://github.com/PrismarineJS/prismarine-chunk/actions?query=workflow%3A%22CI%22) +[![Discord](https://img.shields.io/badge/chat-on%20discord-brightgreen.svg)](https://discord.gg/GsEFRM8) +[![Gitter](https://img.shields.io/badge/chat-on%20gitter-brightgreen.svg)](https://gitter.im/PrismarineJS/general) +[![Irc](https://img.shields.io/badge/chat-on%20irc-brightgreen.svg)](https://irc.gitter.im/) + +[![Try it on gitpod](https://img.shields.io/badge/try-on%20gitpod-brightgreen.svg)](https://gitpod.io/#https://github.com/PrismarineJS/prismarine-chunk) + +A class to hold chunk data for Minecraft: PC 1.8, 1.9, 1.10, 1.11, 1.12, 1.13, 1.14, 1.15 and 1.16 and Bedrock Edition 0.14 and 1.0, 1.16, 1.17, 1.18, 1.19 and 1.20 + +## Usage + +```js +const registry = require('prismarine-registry')('1.8') +const ChunkColumn = require('prismarine-chunk')(registry) +const { Vec3 } = require("vec3") + +const chunk = new ChunkColumn() + +for (let x = 0; x < 16;x++) { + for (let z = 0; z < 16; z++) { + chunk.setBlockType(new Vec3(x, 50, z), 2) + for (let y = 0; y < 256; y++) { + chunk.setSkyLight(new Vec3(x, y, z), 15) + } + } +} + +console.log(JSON.stringify(chunk.getBlock(new Vec3(3,50,3)),null,2)) +``` + +## Test data + +### pc + +Test data can be generated with [minecraftChunkDumper](https://github.com/PrismarineJS/minecraft-chunk-dumper). + +Install it globally with `npm install minecraft-chunk-dumper -g` then run : + +`minecraftChunkDumper saveChunks 1.20 "1_20" 10` + +### bedrock + +Test data can be generated using the included script: +```bash +node tools/generate-bedrock-test-data.mjs +``` + +Example: +```bash +node tools/generate-bedrock-test-data.mjs 1.21.90 -7 10 8403237569561413924 +``` + +This script will generate test data for different caching and hash configurations in the `test/bedrock_/` directory. + +For the version, keep only one chunk column of test data with the following files: +- `level_chunk` without caching (one file per hash/no-hash configuration) +- `level_chunk` with caching (one file per hash/no-hash configuration) +- `level_chunk CacheMissResponse` (one file per hash/no-hash configuration) +- `subchunk` without caching (one file per hash/no-hash configuration) +- `subchunk` with caching (one file per hash/no-hash configuration) +- `subchunk CacheMissResponse` (only one representative file per hash/no-hash configuration) + +Note: bedrock-provider tests network decoding and loading chunks from a save database. The tests in prismarine-chunk test other parts of the chunk API, such as +setting and getting block light, type, biome, entity and block entity data. + +# API + +## Chunk + +#### Chunk(initData: { minY?: number, worldHeight?: number }) + +Build a new chunk. initData is only for 1.18+, and if not given or null the world will default to an old-style 0-256 world. + +#### Chunk.initialize(iniFunc) + +Initialize a chunk. +* `iniFunc` is a function(x,y,z) returning a prismarine-block. + +That function is faster than iterating and calling the setBlock* manually. It is useful to generate a whole chunk and load a whole chunk. + +#### Chunk.version + +returns the version the chunk loader was called with + +#### Chunk.section + +returns ChunkSection class for version + +#### Chunk.getBlock(pos) + +Get the [Block](https://github.com/PrismarineJS/prismarine-block) at [pos](https://github.com/andrewrk/node-vec3) + +`.entity` will have entity NBT data for this block, if it exists + +#### Chunk.setBlock(pos,block) + +Set the [Block](https://github.com/PrismarineJS/prismarine-block) at [pos](https://github.com/andrewrk/node-vec3) + +Set `.entity` property with NBT data for this block to load block entity data for the block + +#### Chunk.getBlockType(pos) + +Get the block type at `pos` + +#### Chunk.getBlockStateId(pos) + +Get the block state id at `pos` + +#### Chunk.getBlockData(pos) + +Get the block data (metadata) at `pos` + +#### Chunk.getBlockLight(pos) + +Get the block light at `pos` + +#### Chunk.getSkyLight(pos) + +Get the block sky light at `pos` + +#### Chunk.getBiome(pos) + +Get the block biome id at `pos` + +#### Chunk.getBiomeColor(pos) + +Get the block biome color at `pos`. Does nothing for PC. + +#### Chunk.setBlockStateId(pos, stateId) + +Set the block type `stateId` at `pos` + +#### Chunk.setBlockType(pos, id) + +Set the block type `id` at `pos` + +#### Chunk.setBlockData(pos, data) + +Set the block `data` (metadata) at `pos` + +#### Chunk.setBlockLight(pos, light) + +Set the block `light` at `pos` + +#### Chunk.setSkyLight(pos, light) + +Set the block sky `light` at `pos` + +#### Chunk.setBiome(pos, biome) + +Set the block `biome` id at `pos` + +#### Chunk.setBiomeColor(pos, biomeColor) + +Set the block `biomeColor` at `pos`. Does nothing for PC. + +#### Chunk.getBlockEntity(pos) + +Returns the block entity data at position if it exists + +#### Chunk.setBlockEntity(pos, nbt) + +Sets block entity data at position + +#### Chunk.loadBlockEntities(nbt) + +Loads an array of NBT data into the chunk column + +#### Chunk.toJson() + +Returns the chunk as json + +#### Chunk.fromJson(j) + +Load chunk from json + +#### Chunk.sections + +Available for pc chunk implementation. +Array of y => section +Can be used to identify whether a section is empty or not (will be null if it's the case) +For version >= 1.9, contains a .palette property which contains all the stateId of this section, can be used to check quickly whether a given block +is in this section. + +### pc + +#### Chunk.getMask() + +Return the chunk bitmap 0b0000_0000_0000_0000(0x0000) means no chunks are set while 0b1111_1111_1111_1111(0xFFFF) means all chunks are set + +#### Chunk.dump() + +Returns the chunk raw data + +#### Chunk.load(data, bitmap = 0xFFFF, skyLightSent = true, fullChunk = true) + +Load raw `data` into the chunk + +#### Chunk.dumpLight() + +Returns the chunk raw light data (starting from 1.14) + +#### Chunk.loadLight(data, skyLightMask, blockLightMask, emptySkyLightMask = 0, emptyBlockLightMask = 0) + +Load lights into the chunk (starting from 1.14) + +#### Chunk.loadParsedLight (skyLight, blockLight, skyLightMask, blockLightMask, emptySkyLightMask, emptyBlockLightMask) + +Load lights into the chunk (starting from 1.17) + +#### Chunk.dumpBiomes() + +Returns the biomes as an array (starting from 1.15) + +#### Chunk.loadBiomes(biomes) + +Load biomes into the chunk (starting from 1.15) + +### bedrock + +See [index.d.ts](https://github.com/PrismarineJS/prismarine-chunk/blob/master/types/index.d.ts#L56) + +## ChunkSection + +### pc + +#### static fromJson(j: any): ChunkSection +#### static sectionSize(skyLightSent?: boolean): number +#### constructor(skyLightSent?: boolean) +#### data: Buffer +#### toJson(): { type: "Buffer"; data: number[]; } +#### initialize(iniFunc: any): void +#### getBiomeColor(pos: Vec3): { r: number; g: number; b: number; } +#### setBiomeColor(pos: Vec3, r: number, g: number, b: number): void +#### getBlockStateId(pos: Vec3): number +#### getBlockType(pos: Vec3): number +#### getBlockData(pos: Vec3): number +#### getBlockLight(pos: Vec3): number +#### getSkyLight(pos: Vec3): number +#### setBlockStateId(pos: Vec3, stateId: number): void +#### setBlockType(pos: Vec3, id: number): void +#### setBlockData(pos: Vec3, data: Buffer): void +#### setBlockLight(pos: Vec3, light: number): void +#### setSkyLight(pos: Vec3, light: number): void +#### dump(): Buffer +#### load(data: Buffer, skyLightSent?: boolean): void + +### bedrock + +See [index.d.ts](https://github.com/PrismarineJS/prismarine-chunk/blob/master/types/index.d.ts#L56) + +#### compact() +Shrinks the size of the SubChunk if possible after setBlock operations are done + +#### getPalette() + +Returns a list of unique block states that make up the chunk section diff --git a/bridge/lib/prismarine-chunk/example.js b/bridge/lib/prismarine-chunk/example.js new file mode 100644 index 0000000..36682df --- /dev/null +++ b/bridge/lib/prismarine-chunk/example.js @@ -0,0 +1,14 @@ +const Chunk = require('./')('1.8') +const Vec3 = require('vec3') + +const chunk = new Chunk() +for (let x = 0; x < Chunk.w; x++) { + for (let z = 0; z < Chunk.l; z++) { + chunk.setBlockType(new Vec3(x, 50, z), 2) + for (let y = 0; y < Chunk.h; y++) { + chunk.setSkyLight(new Vec3(x, y, z), 15) + } + } +} + +console.log(JSON.stringify(chunk.getBlock(new Vec3(3, 50, 3)), null, 2)) diff --git a/bridge/lib/prismarine-chunk/index.js b/bridge/lib/prismarine-chunk/index.js new file mode 100644 index 0000000..d12c5e7 --- /dev/null +++ b/bridge/lib/prismarine-chunk/index.js @@ -0,0 +1 @@ +module.exports = require('./src/index.js') diff --git a/bridge/lib/prismarine-chunk/package.json b/bridge/lib/prismarine-chunk/package.json new file mode 100644 index 0000000..93bd833 --- /dev/null +++ b/bridge/lib/prismarine-chunk/package.json @@ -0,0 +1,60 @@ +{ + "name": "prismarine-chunk", + "version": "1.39.0", + "description": "A class to hold chunk data for prismarine", + "main": "index.js", + "types": "./types/index.d.ts", + "scripts": { + "test": "mocha --reporter spec --exit", + "fix": "standard --fix", + "lint": "standard", + "pretest": "npm run lint" + }, + "repository": { + "type": "git", + "url": "https://github.com/PrismarineJS/prismarine-chunk.git" + }, + "keywords": [ + "minecraft", + "voxel", + "chunk", + "world" + ], + "contributors": [ + "Will Franzen (http://will.xyz/)", + "Romain Beaumont ", + "Georges Oates Larsen (flynnn)", + "mhsjlw", + "hornta", + "Karang", + "Vito Gamberini " + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/PrismarineJS/prismarine-chunk/issues" + }, + "homepage": "https://github.com/PrismarineJS/prismarine-chunk", + "devDependencies": { + "@types/node": "^24.1.0", + "bedrock-protocol": "^3.47.0", + "expect": "^30.0.5", + "minecraft-bedrock-server": "^1.5.0", + "mocha": "^11.0.1", + "prismarine-chunk": "file:.", + "standard": "^17.0.0-2", + "typescript": "^5.0.4" + }, + "dependencies": { + "prismarine-biome": "^1.2.0", + "prismarine-block": "^1.14.1", + "prismarine-nbt": "^2.2.1", + "prismarine-registry": "^1.1.0", + "smart-buffer": "^4.1.0", + "uint4": "^0.1.2", + "vec3": "^0.1.3", + "xxhash-wasm": "^0.4.2" + }, + "engines": { + "node": ">=14" + } +} diff --git a/bridge/lib/prismarine-chunk/src/bedrock/0.14/chunk.js b/bridge/lib/prismarine-chunk/src/bedrock/0.14/chunk.js new file mode 100644 index 0000000..1efd809 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/0.14/chunk.js @@ -0,0 +1,213 @@ +'use strict' + +const Vec3 = require('vec3').Vec3 +const w = 16 +const l = 16 +const h = 128 +const BLOCK_DATA_SIZE = w * l * h +const REGULAR_DATA_SIZE = BLOCK_DATA_SIZE / 2 +const SKYLIGHT_DATA_SIZE = BLOCK_DATA_SIZE / 2 +const BLOCKLIGHT_DATA_SIZE = BLOCK_DATA_SIZE / 2 +const ADDITIONAL_DATA_SIZE_DIRTY = w * l +const ADDITIONAL_DATA_SIZE_COLOR = w * l * 4 +const BUFFER_SIZE = BLOCK_DATA_SIZE + REGULAR_DATA_SIZE + SKYLIGHT_DATA_SIZE + BLOCKLIGHT_DATA_SIZE + ADDITIONAL_DATA_SIZE_COLOR + ADDITIONAL_DATA_SIZE_DIRTY + +const readUInt4LE = require('uint4').readUInt4LE +const writeUInt4LE = require('uint4').writeUInt4LE + +module.exports = loader + +function loader (registry) { + Block = require('prismarine-block')(registry) + Chunk.w = w + Chunk.l = l + Chunk.h = h + Chunk.BUFFER_SIZE = BUFFER_SIZE + Chunk.version = registry.version + return Chunk +} + +let Block + +function exists (val) { + return val !== undefined +} + +const getArrayPosition = function (pos) { + return pos.x + w * (pos.z + l * pos.y) +} + +const getBlockCursor = function (pos) { + return getArrayPosition(pos) +} + +const getBlockDataCursor = function (pos) { + return BLOCK_DATA_SIZE + getArrayPosition(pos) * 0.5 +} + +const getBlockLightCursor = function (pos) { + return BLOCK_DATA_SIZE + REGULAR_DATA_SIZE + getArrayPosition(pos) * 0.5 +} + +const getSkyLightCursor = function (pos) { + return BLOCK_DATA_SIZE + REGULAR_DATA_SIZE + SKYLIGHT_DATA_SIZE + getArrayPosition(pos) * 0.5 +} + +const getHeightMapCursor = function (pos) { + return BLOCK_DATA_SIZE + REGULAR_DATA_SIZE + SKYLIGHT_DATA_SIZE + BLOCKLIGHT_DATA_SIZE + (pos.z * w) + pos.x +} + +const getBiomeCursor = function (pos) { + return BLOCK_DATA_SIZE + REGULAR_DATA_SIZE + SKYLIGHT_DATA_SIZE + BLOCKLIGHT_DATA_SIZE + ADDITIONAL_DATA_SIZE_DIRTY + ((pos.z * w) + pos.x) * 4 +} + +class Chunk { + constructor () { + this.data = Buffer.alloc(BUFFER_SIZE) + + this.data.fill(0) + } + + toJson () { + return JSON.stringify({ data: this.data.toJSON() }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new Chunk() + chunk.data = Buffer.from(parsed.data) + return chunk + } + + initialize (iniFunc) { + const p = new Vec3(0, 0, 0) + for (p.y = 0; p.y < h; p.y++) { + for (p.z = 0; p.z < w; p.z++) { + for (p.x = 0; p.x < l; p.x++) { + const block = iniFunc(p.x, p.y, p.z) + if (block === null) { + continue + } + this.setBlock(p, block) + } + } + } + } + + getBlock (pos) { + const block = new Block(this.getBlockType(pos), this.getBiome(pos), this.getBlockData(pos)) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + return block + } + + setBlock (pos, block) { + if (exists(block.type)) { this.setBlockType(pos, block.type) } + if (exists(block.metadata)) { this.setBlockData(pos, block.metadata) } + if (exists(block.biome)) { this.setBiome(pos, block.biome.id) } + if (exists(block.skyLight)) { this.setSkyLight(pos, block.skyLight) } + if (exists(block.light)) { this.setBlockLight(pos, block.light) } + } + + getBlockType (pos) { + if (pos.y < 0 || pos.y >= 256) return 0 + return this.data.readUInt8(getBlockCursor(pos)) + } + + setBlockType (pos, id) { + if (pos.y < 0 || pos.y >= 256) return + this.data.writeUInt8(id, getBlockCursor(pos)) + } + + getBlockData (pos) { + if (pos.y < 0 || pos.y >= 256) return 0 + return readUInt4LE(this.data, getBlockDataCursor(pos)) + } + + setBlockData (pos, data) { + if (pos.y < 0 || pos.y >= 256) return + writeUInt4LE(this.data, data, getBlockDataCursor(pos)) + } + + getBlockLight (pos) { + if (pos.y < 0 || pos.y >= 256) return 0 + return readUInt4LE(this.data, getBlockLightCursor(pos)) + } + + setBlockLight (pos, light) { + if (pos.y < 0 || pos.y >= 256) return + writeUInt4LE(this.data, light, getBlockLightCursor(pos)) + } + + getSkyLight (pos) { + if (pos.y < 0 || pos.y >= 256) return 0 + return readUInt4LE(this.data, getSkyLightCursor(pos)) + } + + setSkyLight (pos, light) { + if (pos.y < 0 || pos.y >= 256) return + writeUInt4LE(this.data, light, getSkyLightCursor(pos)) + } + + getBiomeColor (pos) { + const color = this.data.readInt32BE(getBiomeCursor(pos)) & 0xFFFFFF + + return { + r: (color >> 16), + g: ((color >> 8) & 0xFF), + b: (color & 0xFF) + } + } + + setBiomeColor (pos, r, g, b) { + this.data.writeInt32BE((this.data.readInt32BE(getBiomeCursor(pos)) & 0xFF000000) | + ((r & 0xFF) << 16) | ((g & 0xFF) << 8) | (b & 0XFF), getBiomeCursor(pos)) + } + + getBiome (pos) { + return (this.data.readInt32BE(getBiomeCursor(pos)) & 0xFF000000) >> 24 + } + + setBiome (pos, id) { + this.data.writeInt32BE((this.data.readInt32BE(getBiomeCursor(pos)) & 0xFFFFFF) | (id << 24), getBiomeCursor(pos)) + } + + getHeight (pos) { + return this.data.readUInt8(getHeightMapCursor(pos)) + } + + setHeight (pos, value) { + this.data.writeUInt8(value, getHeightMapCursor(pos)) + } + + load (data) { + if (!Buffer.isBuffer(data)) { throw (new Error('Data must be a buffer')) } + if (data.length !== BUFFER_SIZE) { throw (new Error(`Data buffer not correct size (was ${data.length}, expected ${BUFFER_SIZE})`)) } + this.data = data + } + + // These methods do nothing, and are present only for API compatibility + dumpBiomes () { + + } + + dumpLight () { + + } + + loadLight () { + + } + + loadBiomes () { + + } + + dump () { + return this.data + } + + getMask () { + return 0xFFFF + } +} diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.0/chunk.js b/bridge/lib/prismarine-chunk/src/bedrock/1.0/chunk.js new file mode 100644 index 0000000..3fce705 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.0/chunk.js @@ -0,0 +1,227 @@ +'use strict' + +const SubChunk = require('./subchunk') +const Vec3 = require('vec3') + +const BIOME_ID_SIZE = 256 +const HEIGHT_SIZE = 256 * 2 + +const BUFFER_SIZE = BIOME_ID_SIZE + HEIGHT_SIZE + +module.exports = loader + +function loader (registry) { + Block = require('prismarine-block')(registry) + Chunk.w = 16 + Chunk.l = 16 + Chunk.h = 256 + Chunk.BUFFER_SIZE = 3 + 256 + 512 + (16 * 10241) + Chunk.version = registry.version + return Chunk +} + +let Block + +function exists (val) { + return val !== undefined +} + +class Chunk { + constructor () { + this.chunks = new Array(16) + for (let i = 0; i < this.chunks.length; i++) { + this.chunks[i] = new SubChunk() + } + + this.data = Buffer.alloc(BUFFER_SIZE) + this.data.fill(0) + + // init biome id + for (let j = 0; j < 256; j++) { + this.data[j] = 1 + } + } + + toJson () { + return JSON.stringify({ data: this.data, sections: this.chunks.map(section => section.toJson()) }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new Chunk() + chunk.data = parsed.data + chunk.chunks = parsed.sections.map(s => SubChunk.fromJson(s)) + return chunk + } + + initialize (iniFunc) { + const p = new Vec3(0, 0, 0) + for (p.y = 0; p.y < Chunk.h; p.y++) { + for (p.z = 0; p.z < Chunk.w; p.z++) { + for (p.x = 0; p.x < Chunk.l; p.x++) { + const block = iniFunc(p.x, p.y, p.z) + if (block === null) { + continue + } + this.setBlock(p, block) + } + } + } + } + + getBlock (pos) { + const block = new Block(this.getBlockType(pos), this.getBiome(pos), this.getBlockData(pos)) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + return block + } + + setBlock (pos, block) { + if (exists(block.type)) { this.setBlockType(pos, block.type) } + if (exists(block.metadata)) { this.setBlockData(pos, block.metadata) } + if (exists(block.biome)) { this.setBiome(pos, block.biome.id) } + if (exists(block.skyLight)) { this.setSkyLight(pos, block.skyLight) } + if (exists(block.light)) { this.setBlockLight(pos, block.light) } + } + + getBlockType (pos) { + const chunk = this.chunks[pos.y >> 4] + return chunk ? chunk.getBlockType(new Vec3(pos.x, pos.y - 16 * (pos.y >> 4), pos.z)) : 0 + } + + setBlockType (pos, type) { + const chunk = this.chunks[pos.y >> 4] + return chunk && chunk.setBlockType(new Vec3(pos.x, pos.y - 16 * (pos.y >> 4), pos.z), type) + } + + getBlockData (pos) { + const chunk = this.chunks[pos.y >> 4] + return chunk ? chunk.getBlockData(new Vec3(pos.x, pos.y - 16 * (pos.y >> 4), pos.z)) : 0 + } + + setBlockData (pos, data) { + const chunk = this.chunks[pos.y >> 4] + return chunk && chunk.setBlockData(new Vec3(pos.x, pos.y - 16 * (pos.y >> 4), pos.z), data) + } + + getBlockLight (pos) { + const chunk = this.chunks[pos.y >> 4] + return chunk ? chunk.getBlockLight(new Vec3(pos.x, pos.y - 16 * (pos.y >> 4), pos.z)) : 0 + } + + setBlockLight (pos, light) { + const chunk = this.chunks[pos.y >> 4] + return chunk && chunk.setBlockLight(new Vec3(pos.x, pos.y - 16 * (pos.y >> 4), pos.z), light) + } + + getSkyLight (pos) { + const chunk = this.chunks[pos.y >> 4] + return chunk ? chunk.getSkyLight(new Vec3(pos.x, pos.y - 16 * (pos.y >> 4), pos.z)) : 15 + } + + setSkyLight (pos, light) { + const chunk = this.chunks[pos.y >> 4] + return chunk && chunk.setSkyLight(new Vec3(pos.x, pos.y - 16 * (pos.y >> 4), pos.z), light) + } + + getBiomeColor (pos) { + return { x: 0, y: 0, z: 0 } + } + + setBiomeColor (pos, r, g, b) { + // no longer a feature ;( + } + + getBiome (pos) { + return this.data.readUInt8((pos.z << 4) + (pos.x)) + } + + setBiome (pos, id) { + this.data.writeUInt8(id, (pos.z << 4) + (pos.x)) + } + + getHeight (pos) { + return this.data.readUInt8((pos.z << 4) + (pos.x)) + } + + setHeight (pos, height) { + this.data.writeUInt8(height, (pos.z << 4) + (pos.x)) + } + + load (newData) { + if (!Buffer.isBuffer(newData)) { throw (new Error('Data must be a buffer')) } + + let offset = 0 + const numberOfChunks = newData.readUInt8(offset) + offset += 1 + + if (((numberOfChunks * 10241) + 1) > newData.length) { + throw (new Error(`Data buffer not correct size (was ${newData.length}, expected ${3 + 256 + 512 + (16 * 10241)})`)) + } + + for (let i = 0; i < numberOfChunks; i++) { + this.chunks[i].load(newData.slice(offset, offset + 10241)) + offset += 10241 + } + + // ignore the rest + } + + size () { + let size = 1 // count of subchunks (byte) + size += this.chunks.length * 10241 // all of the chunks and their size + size += HEIGHT_SIZE + size += BIOME_ID_SIZE + size += 1 // border block count (byte) + size += 1 // signed varint block extradata count + return size + } + + // These methods do nothing, and are present only for API compatibility + dumpBiomes () { + + } + + dumpLight () { + + } + + loadLight () { + + } + + loadBiomes () { + + } + + dump () { + let offset = 0 + + const exportData = Buffer.alloc(this.size()) + exportData.fill(0) + + exportData.writeUInt8(this.chunks.length, offset) + offset += 1 + + for (let i = 0; i < this.chunks.length; i++) { + const dump = this.chunks[i].dump() + dump.copy(exportData, offset) + offset += dump.length + } + + this.data.copy(exportData, offset) + offset += this.data.length + + exportData.writeUInt8(0, offset) // border block count + offset += 1 + + exportData.writeUInt8(0, offset) // signed varint ?! (extdata count) + offset += 1 + + return exportData + } + + getMask () { + return 0xFFFF + } +} diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.0/subchunk.js b/bridge/lib/prismarine-chunk/src/bedrock/1.0/subchunk.js new file mode 100644 index 0000000..884cae5 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.0/subchunk.js @@ -0,0 +1,76 @@ +'use strict' + +const readUInt4LE = require('uint4').readUInt4LE +const writeUInt4LE = require('uint4').writeUInt4LE + +const BLOCK_SIZE = 16 * 16 * 16 +const METADATA_SIZE = (16 * 16 * 16) / 2 +const SKYLIGHT_SIZE = (16 * 16 * 16) / 2 +const BLOCKLIGHT_SIZE = (16 * 16 * 16) / 2 +const BUFFER_SIZE = 1 + (BLOCK_SIZE + METADATA_SIZE + BLOCKLIGHT_SIZE + SKYLIGHT_SIZE) + +function getIndex (pos) { + return 1 + ((pos.x * 256) + (pos.z * 16) + pos.y) // 1 + is for the version code :P +} + +class SubChunk { + constructor () { + this.data = Buffer.alloc(BUFFER_SIZE) + this.data.fill(0) + } + + toJson () { + return JSON.stringify({ data: this.data.toJSON() }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new SubChunk() + chunk.data = Buffer.from(parsed.data) + return chunk + } + + getBlockType (pos) { + return this.data.readUInt8(getIndex(pos)) + } + + setBlockType (pos, type) { + this.data.writeUInt8(type, getIndex(pos)) + } + + getBlockLight (pos) { + return readUInt4LE(this.data, BLOCK_SIZE + METADATA_SIZE + SKYLIGHT_SIZE + (getIndex(pos) / 2)) + } + + setBlockLight (pos, light) { + writeUInt4LE(this.data, light, BLOCK_SIZE + METADATA_SIZE + SKYLIGHT_SIZE + (getIndex(pos) / 2)) + } + + getSkyLight (pos) { + return readUInt4LE(this.data, BLOCK_SIZE + METADATA_SIZE + (getIndex(pos) / 2)) + } + + setSkyLight (pos, light) { + writeUInt4LE(this.data, light, BLOCK_SIZE + METADATA_SIZE + (getIndex(pos) / 2)) + } + + getBlockData (pos) { + return readUInt4LE(this.data, BLOCK_SIZE + (getIndex(pos) / 2)) + } + + setBlockData (pos, data) { + writeUInt4LE(this.data, data, BLOCK_SIZE + (getIndex(pos) / 2)) + } + + load (data) { + if (!Buffer.isBuffer(data)) { throw (new Error('Data must be a buffer')) } + if (data.length !== BUFFER_SIZE) { throw (new Error(`Data buffer not correct size (was ${data.length}, expected ${BUFFER_SIZE})`)) } + this.data = data + } + + dump () { + return this.data + } +} + +module.exports = SubChunk diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.18/BiomeSection.js b/bridge/lib/prismarine-chunk/src/bedrock/1.18/BiomeSection.js new file mode 100644 index 0000000..c51dff8 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.18/BiomeSection.js @@ -0,0 +1,131 @@ +const { StorageType } = require('../common/constants') +const PalettedStorage = require('../common/PalettedStorage') +const neededBits = require('../../pc/common/neededBits') + +class BiomeSection { + constructor (registry, y, options = {}) { + this.Biome = require('prismarine-biome')(registry) + this.y = y + this.biomes = options.biomes ? PalettedStorage.copyFrom(options.biomes) : new PalettedStorage(1) + this.palette = options.palette || [0] + } + + readLegacy2D (stream) { + for (let x = 0; x < 16; x++) { + for (let z = 0; z < 16; z++) { + this.setBiomeId(x, 0, z, stream.readByte()) + } + } + } + + copy (other) { + this.biomes = PalettedStorage.copyFrom(other.biomes) + this.palette = JSON.parse(JSON.stringify(other.palette)) + this.y = other.y + } + + read (type, buf, previousSection) { + this.palette = [] + const paletteType = buf.readByte() + // below should always be 1, so we use numerical IDs + const usingNetworkRuntimeIds = paletteType & 1 + if (usingNetworkRuntimeIds !== 1) throw new Error('Biome palette type must be set to use runtime IDs') + const bitsPerBlock = paletteType >> 1 + + if (bitsPerBlock === 0) { + this.palette.push(type === StorageType.LocalPersistence ? buf.readInt32LE() : (buf.readVarInt() >> 1)) + return // short circuit + } + + this.biomes = new PalettedStorage(bitsPerBlock) + this.biomes.read(buf) + + // now read palette + if (type === StorageType.Runtime || type === StorageType.NetworkPersistence) { + // Shift 1 bit to un-zigzag (we cannot be negative) + const biomePaletteLength = buf.readVarInt() >> 1 + for (let i = 0; i < biomePaletteLength; i++) { + this.palette.push(buf.readVarInt() >> 1) + } + } else { + const biomePaletteLength = buf.readInt32LE() + for (let i = 0; i < biomePaletteLength; i++) { + this.palette.push(buf.readInt32LE()) + } + } + } + + // TODO: handle downsizing + setBiomeId (x, y, z, biomeId) { + if (!this.palette.includes(biomeId)) { + this.palette.push(biomeId) + } + + const minBits = neededBits(this.palette.length - 1) + if (minBits > this.biomes.bitsPerBlock) { + this.biomes = this.biomes.resize(minBits) + } + + this.biomes.set(x, y, z, this.palette.indexOf(biomeId)) + } + + getBiomeId (x, y, z) { + return this.palette[this.biomes.get(x, y, z)] + } + + getBiome (pos) { + return new this.Biome(this.getBiomeId(pos.x, pos.y, pos.z)) + } + + setBiome (pos, biome) { + this.setBiomeId(pos.x, pos.y, pos.z, biome.id) + } + + export (type, stream) { + const bitsPerBlock = Math.ceil(Math.log2(this.palette.length)) + const paletteType = (bitsPerBlock << 1) | (type === StorageType.Runtime) + stream.writeUInt8(paletteType) + if (bitsPerBlock === 0) { + if (type === StorageType.LocalPersistence) { + stream.writeInt32LE(this.palette[0]) + } else { + stream.writeVarInt(this.palette[0] << 1) + } + return // short circuit + } + + this.biomes.write(stream) + if (type === StorageType.Runtime) { + stream.writeVarInt(this.palette.length << 1) + for (const biome of this.palette) { + stream.writeVarInt(biome << 1) + } + } else { + stream.writeUInt32LE(this.palette.length) + for (const biome of this.palette) { + stream.writeUInt32LE(biome) + } + } + } + + // Just write the top most layer biomes + exportLegacy2D (stream) { + for (let x = 0; x < 16; x++) { + for (let z = 0; z < 16; z++) { + const y = 0 + const biome = this.getBiomeId(x, y, z) + stream.writeUInt8(biome) + } + } + } + + toObject () { + return { + y: this.y, + biomes: this.biomes, + palette: this.palette + } + } +} + +module.exports = BiomeSection diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.18/ChunkColumn.js b/bridge/lib/prismarine-chunk/src/bedrock/1.18/ChunkColumn.js new file mode 100644 index 0000000..65e8a79 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.18/ChunkColumn.js @@ -0,0 +1,301 @@ +const ChunkColumn13 = require('../1.3/ChunkColumn') +const SubChunk = require('./SubChunk') +const BiomeSection = require('./BiomeSection') +const ProxyBiomeSection = require('./ProxyBiomeSection') + +const { BlobType, BlobEntry } = require('../common/BlobCache') +const { StorageType } = require('../common/constants') +const Stream = require('../common/Stream') + +const nbt = require('prismarine-nbt') + +class ChunkColumn180 extends ChunkColumn13 { + Section = SubChunk + bounds = this.setBounds(-4, 20) // World height changed + + // BIOMES + + getBiome (pos) { + return new this.Biome(this.getBiomeId(pos)) + } + + setBiome (pos, biome) { + this.setBiomeId(pos, biome.id) + } + + getBiomeId (pos) { + const Y = pos.y >> 4 + const sec = this.biomes[this.co + Y] + return sec?.getBiomeId(pos.x, pos.y & 0xf, pos.z) ?? 0 + } + + setBiomeId (pos, biomeId) { + const Y = pos.y >> 4 + let sec = this.biomes[this.co + Y] + if (!sec) { + this.biomes[this.co + Y] = sec = new BiomeSection(this.registry, Y) + } else if (!sec.setBiomeId) { + this.biomes[this.co + Y] = sec = sec.promote(Y) + } + sec.setBiomeId(pos.x, pos.y & 0xf, pos.z, biomeId) + this.biomesUpdated = true + } + + // Load 3D biome data + loadBiomes (buf, storageType) { + if (buf instanceof Buffer) buf = new Stream(buf) + this.biomes = [] + let last + for (let y = this.minCY; buf.peek(); y++) { + if (buf.peek() === 0xff) { // re-use the last data + if (!last) throw new Error('No last biome') + const biome = new ProxyBiomeSection(this.registry, last) + this.biomes.push(biome) + // skip peek'ed + buf.readByte() + } else { + const biome = new BiomeSection(this.registry, y) + biome.read(storageType, buf) + last = biome + this.biomes.push(biome) + } + } + } + + writeBiomes (stream) { + for (let i = 0; i < (this.worldHeight >> 4); i++) { + let biomeSection = this.biomes[i] + if (!biomeSection) { + if (this.biomes[i - 1]) { + this.biomes[i] = biomeSection = new ProxyBiomeSection(this.registry, this.biomes[i - 1]) + } else { + this.biomes[i] = biomeSection = new BiomeSection(this.registry, i) + } + } + biomeSection.export(StorageType.Runtime, stream) + } + } + + // CACHING + + /** + * Encodes this chunk column for the network with no caching + * @param buffer Full chunk buffer + */ + async networkEncodeNoCache () { + const stream = new Stream() + for (const biomeSection of this.biomes) { + biomeSection.export(StorageType.Runtime, stream) + } + const biomeBuf = stream.getBuffer() + return Buffer.concat([ + biomeBuf, + Buffer.from([0]) // border blocks count + ]) + } + + /** + * Encodes this chunk column for use over network with caching enabled + * + * @param blobStore The blob store to write chunks in this section to + * @returns {Promise} The blob hashes for this chunk, the last one is biomes, rest are sections + */ + async networkEncodeBlobs (blobStore, includeBiomes) { + const blobHashes = [] + if (includeBiomes) { + if (this.biomesUpdated || !this.biomesHash || !blobStore.get(this.biomesHash.toString())) { + const stream = new Stream() + for (const biomeSection of this.biomes) { + biomeSection.export(StorageType.Runtime, stream) + } + const biomeBuf = stream.getBuffer() + await this.updateBiomeHash(biomeBuf) + + this.biomesUpdated = false + blobStore.set(this.biomesHash.toString(), new BlobEntry({ x: this.x, z: this.z, type: BlobType.Biomes, buffer: this.biomes })) + } + blobHashes.push({ hash: this.biomesHash, type: BlobType.Biomes }) + } + + return blobHashes + } + + async networkEncode (blobStore) { + const blobs = await this.networkEncodeBlobs(blobStore, true, false) + + return { + blobs, // cache blobs + payload: Buffer.concat([ // non-cached stuff + Buffer.from([0]) // border blocks + ]) + } + } + + networkDecodeNoCache (buffer, sectionCount) { + const stream = buffer instanceof Buffer ? new Stream(buffer) : buffer + + if (sectionCount !== -1 && sectionCount !== -2) { // In 1.18+, with sectionCount as -1/-2 we only get the biomes here + this.sections = [] + for (let i = 0; i < sectionCount; i++) { + // in 1.17.30+, chunk index is sent in payload + const section = new SubChunk(this.registry, this.Block, { y: i, subChunkVersion: this.subChunkVersion }) + section.decode(StorageType.Runtime, stream) + this.setSection(i, section) + } + } + + this.loadBiomes(stream, StorageType.Runtime) + const borderBlocks = stream.readBuffer(stream.readZigZagVarInt()) + if (borderBlocks.length) { + throw new Error(`Can't handle border blocks (length: ${borderBlocks.length})`) + } + + let startOffset = stream.readOffset + while (stream.peek() === 0x0A) { + const { data, metadata } = nbt.protos.littleVarint.parsePacketBuffer('nbt', stream.buffer, startOffset) + stream.readOffset += metadata.size + startOffset += metadata.size + this.addBlockEntity(data) + } + } + + /** + * Decodes cached chunks sent over the network + * @param blobs The blob hashes sent in the Chunk packet + * @param blobStore Our blob store for cached data + * @param {Buffer} payload The rest of the non-cached data + * @returns {CCHash[]} A list of hashes we don't have and need. If len > 0, decode failed. + */ + async networkDecode (blobs, blobStore, payload) { + if (payload) { + const stream = new Stream(payload) + const borderblocks = stream.readBuffer(stream.readZigZagVarInt()) + + if (borderblocks.length) { + throw new Error(`Can't handle border blocks (length: ${borderblocks.length})`) + } + } + + // Block NBT data is now inside individual sections + + const misses = [] + for (const blob of blobs) { + if (!blobStore.has(blob.toString())) { + misses.push(blob) + } + } + if (misses.length > 0) { + // missing blobs, call this again once the server replies with our MISSing + // blobs and don't try to load this column until we have all the data + return misses + } + + // Reset the sections & length, when we add a section, it will auto increment + this.sections = [] + for (const blob of blobs) { + const entry = blobStore.get(blob.toString()) + if (entry.type === BlobType.Biomes) { + const stream = new Stream(entry.buffer) + this.loadBiomes(stream, StorageType.NetworkPersistence, blob) + } else if (entry.type === BlobType.ChunkSection) { + throw new Error("Can't accept chunk sections in networkDecode, these Blobs should be sent as individual sections") + } else { + throw Error('Unknown blob type: ' + entry.type) + } + } + + return misses // return empty array if everything was loaded + } + + async networkDecodeSubChunkNoCache (y, buffer) { + const stream = new Stream(buffer) + const section = new SubChunk(this.registry, this.Block, { y, subChunkVersion: this.subChunkVersion }) + section.decode(StorageType.Runtime, stream) + this.setSection(y, section) + + let startOffset = stream.readOffset + while (stream.peek() === 0x0A) { + const { data, metadata } = nbt.protos.littleVarint.parsePacketBuffer('nbt', buffer, startOffset) + stream.readOffset += metadata.size + startOffset += metadata.size + this.addBlockEntity(data) + } + } + + async networkEncodeSubChunkNoCache (y) { + const tiles = this.getSectionBlockEntities(y) + + const section = this.getSectionAtIndex(y) + const subchunk = await section.encode(StorageType.Runtime, false, this.compactOnSave) + + const tileBufs = [] + for (const tile of tiles) { + tileBufs.push(nbt.writeUncompressed(tile, 'littleVarint')) + } + + return Buffer.concat([subchunk, ...tileBufs]) + } + + async networkDecodeSubChunk (blobs, blobStore, payload) { + if (payload) { + const nbtStream = new Stream(payload) + let startOffset = 0 + while (nbtStream.peekUInt8() === 0x0A) { + const { data, metadata } = nbt.protos.littleVarint.parsePacketBuffer('nbt', payload, startOffset) + nbtStream.readOffset += metadata.size + startOffset += metadata.size + this.addBlockEntity(data) + } + } + + const misses = [] + for (const blob of blobs) { + if (!blobStore.has(blob.toString())) { + misses.push(blob) + } + } + if (misses.length > 0) { + // missing blobs, call this again once the server replies with our MISSing + // blobs and don't try to load this column until we have all the data + return misses + } + + // Reset the sections & length, when we add a section, it will auto increment + this.sections = [] + this.sectionsLen = 0 + for (const blob of blobs) { + const entry = blobStore.get(blob.toString()) + + const stream = new Stream(entry.buffer) + const subchunk = new SubChunk(this.registry, this.Block, { y: blob.y, subChunkVersion: this.subChunkVersion }) + await subchunk.decode(StorageType.Runtime, stream) + this.setSection(subchunk.y, subchunk) + } + + return misses // return empty array if everything was loaded + } + + async networkEncodeSubChunk (y, blobStore) { + const tiles = this.getSectionBlockEntities(y) + const section = this.getSectionAtIndex(y) + + if (section.updated) { + const terrainBuffer = await section.encode(StorageType.Runtime, true, this.compactOnSave) // note Runtime, not NetworkPersistence + const blob = new BlobEntry({ x: this.x, y: section.y, z: this.z, type: BlobType.ChunkSection, buffer: terrainBuffer }) + blobStore.set(section.hash.toString(), blob) + } + + const tileBufs = [] + for (const tile of tiles) { + tileBufs.push(nbt.writeUncompressed(tile, 'littleVarint')) + } + + return [section.hash, Buffer.concat(tileBufs)] + } + + toObject () { + return { ...super.toObject(), version: this.registry.version.minecraftVersion } + } +} + +module.exports = ChunkColumn180 diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.18/ProxyBiomeSection.js b/bridge/lib/prismarine-chunk/src/bedrock/1.18/ProxyBiomeSection.js new file mode 100644 index 0000000..0676ac9 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.18/ProxyBiomeSection.js @@ -0,0 +1,47 @@ +const BiomeSection = require('./BiomeSection') + +/** + * Micro-optimization from Mojang's 1.22.x chunk implementation.. + * Biome sections can point to n-1 section if the data is the same in section `n` and `n-1` + * How it works: Server sends biome data for each chunk section from ground up to the world section height. + * Ground up, if the biome data is the same as the previous section, it is encoded with a special 0xff + * bitsPerValue which indicates to the client that the biome data is the same as the previous section. + * We implement that here with ProxyBiomeSection which sends all the get's over to the previous section. + * This can be chained to any number of sections, as a linked list. When updating the biome data, ChunkColumn + * will call the promote() function to turn this into a proper BiomeSection and update the BS pointer. + * + * Another possible way to implement would be to just read these sections as null + * + * We don't need to implement this optimization, but we do to make buffer equality tests pass when + * decoding/re-encoding Minecraft network chunk data. + */ +class ProxyBiomeSection { + constructor (registry, target) { + this.registry = registry + this.target = target + } + + getBiome (pos) { + return this.target.getBiome(pos) + } + + getBiomeId (pos) { + return this.target.getBiomeId(pos) + } + + copy (other) { + return this.target.copy(other) + } + + promote (y) { + const biome = new BiomeSection(this.registry, y) + biome.copy(this.target) + return biome + } + + export (format, stream) { + stream.writeUInt8(0xff) + } +} + +module.exports = ProxyBiomeSection diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.18/SubChunk.js b/bridge/lib/prismarine-chunk/src/bedrock/1.18/SubChunk.js new file mode 100644 index 0000000..120b461 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.18/SubChunk.js @@ -0,0 +1,38 @@ +const SubChunk13 = require('../1.3/SubChunk') +const { StorageType } = require('../common/constants') +const PalettedStorage = require('../common/PalettedStorage') + +class SubChunk118 extends SubChunk13 { + loadRuntimePalette (storageLayer, stream, paletteSize) { + this.palette[storageLayer] = [] + + for (let i = 0; i < paletteSize; i++) { + const stateId = stream.readZigZagVarInt() + const block = this.registry.blocksByStateId[stateId] + this.palette[storageLayer][i] = { stateId, ...block, count: 0 } + } + } + + loadPalettedBlocks (storageLayer, stream, bitsPerBlock, format) { + if ((format === StorageType.Runtime) && (bitsPerBlock === 0)) { + this.palette[storageLayer] = [] + this.blocks[storageLayer] = new PalettedStorage(1) + const stateId = stream.readZigZagVarInt() + this.addToPalette(storageLayer, stateId) + return + } + return super.loadPalettedBlocks(...arguments) + } + + writeStorage (stream, storageLayer, format) { + if ((format === StorageType.Runtime) && (this.palette[storageLayer].length === 1)) { + stream.writeUInt8(1) // palette type + stream.writeZigZagVarInt(this.palette[storageLayer][0].stateId) + return + } + + return super.writeStorage(...arguments) + } +} + +module.exports = SubChunk118 diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.18/chunk.js b/bridge/lib/prismarine-chunk/src/bedrock/1.18/chunk.js new file mode 100644 index 0000000..bac9e0e --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.18/chunk.js @@ -0,0 +1,19 @@ +const { ChunkVersion } = require('../common/constants') +const ChunkColumn = require('./ChunkColumn') + +module.exports = (version) => { + const registry = version.blockRegistry || version + const Block = require('prismarine-block')(registry) + const Biome = require('prismarine-biome')(registry) + return class Chunk extends ChunkColumn { + constructor (options) { + super(options, registry, Block, Biome) + this.chunkVersion = this.chunkVersion || ChunkVersion.v1_18_0 + this.subChunkVersion = 9 + } + + static fromJson (str) { + return new this(JSON.parse(str)) + } + } +} diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.3/ChunkColumn.js b/bridge/lib/prismarine-chunk/src/bedrock/1.3/ChunkColumn.js new file mode 100644 index 0000000..926578c --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.3/ChunkColumn.js @@ -0,0 +1,277 @@ +const CommonChunkColumn = require('../common/CommonChunkColumn') +const SubChunk = require('./SubChunk') +const BiomeSection = require('../1.18/BiomeSection') +const { StorageType } = require('../common/constants') +const Stream = require('../common/Stream') +const { BlobType, BlobEntry } = require('../common/BlobCache') +const nbt = require('prismarine-nbt') +const { getChecksum } = require('../common/util') + +class ChunkColumn13 extends CommonChunkColumn { + Section = SubChunk + + constructor (options = {}, registry, Block, Biome) { + super(options, registry) + this.Block = Block + this.Biome = Biome + this.biomes = [] + this.sections = [] + this.biomesUpdated = true + if (options.sections?.length) { + for (const section of options.sections) { + if (section) { + this.sections.push(new this.Section(registry, Block, section)) + } else { + this.sections.push(null) + } + } + } + + if (options.biomes?.length) { + for (const biome of options.biomes) { + this.biomes.push(biome ? new BiomeSection(registry, biome.y, biome) : null) + } + } else { + this.biomes.push(new BiomeSection(registry, 0)) + } + } + + getBiome (pos) { + return new this.Biome(this.biomes[0].getBiomeId(pos.x, 0, pos.z)) + } + + setBiome (pos, biome) { + this.biomes[0].setBiomeId(pos.x, 0, pos.z, biome.id) + this.biomesUpdated = true + } + + getBiomeId (pos) { + return this.biomes[0].getBiomeId(pos.x, 0, pos.z) + } + + setBiomeId (pos, biome) { + this.biomes[0].setBiomeId(pos.x, 0, pos.z, biome) + this.biomesUpdated = true + } + + loadLegacyBiomes (buf) { + if (buf instanceof Buffer) buf = new Stream(buf) + const biome = new BiomeSection(this.registry, 0) + biome.readLegacy2D(buf) + this.biomes = [biome] + } + + // Load heightmap data + /** @type Uint16Array */ + loadHeights (heightmap) { + this.heights = heightmap + } + + getHeights () { + return this.heights + } + + writeLegacyBiomes (stream) { + this.biomes[0].exportLegacy2D(stream) + } + + writeHeightMap (stream) { + if (!this.heights) { + this.heights = new Uint16Array(256) + } + for (let i = 0; i < 256; i++) { + stream.writeUInt16LE(this.heights[i]) + } + } + + async updateBiomeHash (fromBuf) { + this.biomesUpdated = false + this.biomesHash = await getChecksum(fromBuf) + return this.biomesHash + } + + /** + * Encodes this chunk column for the network with no caching + * @param buffer Full chunk buffer + */ + async networkEncodeNoCache () { + const tileBufs = [] + for (const key in this.blockEntities) { + const tile = this.blockEntities[key] + tileBufs.push(nbt.writeUncompressed(tile, 'littleVarint')) + } + + let biomeBuf + const stream = new Stream(Buffer.alloc(256)) + if (this.biomes[0]) { + this.biomes[0].exportLegacy2D(stream) + biomeBuf = stream.buffer + } else { + throw Error('No biome section') + } + + const sectionBufs = [] + for (const section of this.sections) { + sectionBufs.push(await section.encode(StorageType.Runtime, false, this.compactOnSave)) + } + return Buffer.concat([ + ...sectionBufs, + biomeBuf, + Buffer.from([0]), // border blocks count + ...tileBufs // block entities + ]) + } + + /** + * Encodes this chunk column for use over network with caching enabled + * + * @param blobStore The blob store to write chunks in this section to + * @returns {Promise} The blob hashes for this chunk, the last one is biomes, rest are sections + */ + async networkEncodeBlobs (blobStore) { + const blobHashes = [] + for (const section of this.sections) { + if (section.updated || !blobStore.get(section.hash)) { + const buffer = await section.encode(StorageType.NetworkPersistence, true, this.compactOnSave) + const blob = new BlobEntry({ x: this.x, y: section.y, z: this.z, type: BlobType.ChunkSection, buffer }) + blobStore.set(section.hash, blob) + } + blobHashes.push({ hash: section.hash, type: BlobType.ChunkSection }) + } + if (this.biomesUpdated || !this.biomesHash || !blobStore.get(this.biomesHash)) { + if (this.biomes[0]) { + const stream = new Stream() + this.biomes[0].exportLegacy2D(stream) + await this.updateBiomeHash(stream.getBuffer()) + } else { + await this.updateBiomeHash(Buffer.alloc(256)) + } + + this.biomesUpdated = false + blobStore.set(this.biomesHash, new BlobEntry({ x: this.x, z: this.z, type: BlobType.Biomes, buffer: this.biomes })) + } + blobHashes.push({ hash: this.biomesHash, type: BlobType.Biomes }) + return blobHashes + } + + async networkEncode (blobStore) { + const blobs = await this.networkEncodeBlobs(blobStore) + const tileBufs = [] + for (const key in this.blockEntities) { + const tile = this.blockEntities[key] + tileBufs.push(nbt.writeUncompressed(tile, 'littleVarint')) + } + + return { + blobs, // cache blobs + payload: Buffer.concat([ // non-cached stuff + Buffer.from([0]), // border blocks + ...tileBufs + ]) + } + } + + networkDecodeNoCache (buffer, sectionCount) { + const stream = buffer instanceof Buffer ? new Stream(buffer) : buffer + + if (sectionCount === -1) { // In 1.18+, with sectionCount as -1 we only get the biomes here + throw new RangeError('-1 section count not supported below 1.18') + } + + this.sections = [] + for (let i = 0; i < sectionCount; i++) { + // in 1.17.30+, chunk index is sent in payload + const section = new SubChunk(this.registry, this.Block, { y: i, subChunkVersion: this.subChunkVersion }) + section.decode(StorageType.Runtime, stream) + this.setSection(i, section) + } + + const biomes = new BiomeSection(this.registry, 0) + biomes.readLegacy2D(stream) + this.biomes = [biomes] + + const borderBlocksLength = stream.readZigZagVarInt() + const borderBlocks = stream.readBuffer(borderBlocksLength) + // Don't know how to handle this yet + if (borderBlocks.length) throw Error(`Read ${borderBlocksLength} border blocks, expected 0`) + + let startOffset = stream.readOffset + while (stream.peek() === 0x0A) { + const { data, metadata } = nbt.protos.littleVarint.parsePacketBuffer('nbt', stream.buffer, startOffset) + stream.readOffset += metadata.size + startOffset += metadata.size + this.addBlockEntity(data) + } + } + + /** + * Decodes cached chunks sent over the network + * @param blobs The blob hashes sent in the Chunk packet + * @param blobStore Our blob store for cached data + * @param {Buffer} payload The rest of the non-cached data + * @returns {CCHash[]} A list of hashes we don't have and need. If len > 0, decode failed. + */ + async networkDecode (blobs, blobStore, payload) { + // We modify blobs, need to make a copy here + blobs = [...blobs] + if (!blobs.length) { + throw new Error('No blobs to decode') + } + + if (payload) { + const stream = new Stream(payload) + const borderblocks = stream.readBuffer(stream.readByte()) + if (borderblocks.length) { + throw new Error('cannot handle border blocks (read length: ' + borderblocks.length + ')') + } + + let startOffset = stream.readOffset + while (stream.peek() === 0x0A) { + const { metadata, data } = nbt.protos.littleVarint.parsePacketBuffer('nbt', payload, startOffset) + stream.readOffset += metadata.size + startOffset += metadata.size + this.addBlockEntity(data) + } + } + + const misses = [] + for (const blob of blobs) { + if (!blobStore.has(blob)) { + misses.push(blob) + } + } + if (misses.length > 0) { + // missing things, call this again once the server replies with our MISSing + // blobs and don't try to load this column until we have all the data + return misses + } + + // Reset the sections & length, when we add a section, it will auto increment + this.sections = [] + this.sectionsLen = 0 + const biomeBlob = blobs.pop() + + for (let i = 0; i < blobs.length; i++) { + const blob = blobStore.get(blobs[i]) + const section = new SubChunk(this.registry, this.Block, { y: i, subChunkVersion: this.subChunkVersion }) + section.decode(StorageType.NetworkPersistence, blob.buffer) + this.setSection(i, section) + } + + const biomeEntry = blobStore.get(biomeBlob) + this.loadLegacyBiomes(biomeEntry.buffer) + + return misses // should be empty + } + + toObject () { + const biomes = this.biomes.map(b => b?.toObject()) + return { ...super.toObject(), biomes, biomesUpdated: this.biomesUpdated, version: this.registry.version.minecraftVersion } + } + + toJson () { + return JSON.stringify(this.toObject()) + } +} + +module.exports = ChunkColumn13 diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.3/SubChunk.js b/bridge/lib/prismarine-chunk/src/bedrock/1.3/SubChunk.js new file mode 100644 index 0000000..eaca32b --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.3/SubChunk.js @@ -0,0 +1,365 @@ +const PalettedStorage = require('../common/PalettedStorage') +const { StorageType } = require('../common/constants') +const { getChecksum } = require('../common/util') +const neededBits = require('../../pc/common/neededBits') +const Stream = require('../common/Stream') +const nbt = require('prismarine-nbt') + +class SubChunk { + constructor (registry, Block, options = {}) { + this.registry = registry + if (!registry) { + throw new Error('registry is required') + } + this.Block = Block + this.y = options.y + this.palette = options.palette || [] + this.blocks = [] + if (options.blocks) { + for (const block of options.blocks) { + this.blocks.push(PalettedStorage.copyFrom(block)) + } + } + this.subChunkVersion = options.subChunkVersion || 8 + this.hash = options.hash || null + this.updated = options.updated || true + + // Not written or read + this.blockLight = options.blockLight ? PalettedStorage.copyFrom(options.blockLight) : new PalettedStorage(4) + this.skyLight = options.skyLight ? PalettedStorage.copyFrom(options.skyLight) : new PalettedStorage(4) + } + + // Creates an air chunk + static create (registry, Block, y) { + const subChunk = new this(registry, Block, { y }) + // Fill first layer with zero + subChunk.blocks.push(new PalettedStorage(1)) + subChunk.palette.push([]) + // Set zero to be air, Add to the palette + subChunk.addToPalette(0, subChunk.registry.blocksByName.air.defaultState, 4096) + return subChunk + } + + decode (format, stream) { + if (stream instanceof Buffer) stream = new Stream(stream) + // version + this.subChunkVersion = stream.readByte() + let storageCount = 1 + + switch (this.subChunkVersion) { + case 1: + // This is a old SubChunk format that only has one layer - no need to read storage count + // But when re-encoding, we want to use v8 to not loose data + this.subChunkVersion = 8 + break + case 8: + case 9: + storageCount = stream.readByte() + if (this.subChunkVersion >= 9) { + this.y = stream.readInt8() // Sub Chunk Index + } + if (storageCount > 2) { + // This is technically not an error, but not currently aware of any servers + // that send more than two layers. If this is a problem, this check can be + // safely removed. Just keeping it here as a sanity check. + throw new Error('Expected storage count to be 1 or 2, got ' + storageCount) + } + break + default: + throw new Error('Unsupported sub chunk version: ' + this.subChunkVersion) + } + + for (let i = 0; i < storageCount; i++) { + const paletteType = stream.readByte() + const usingNetworkRuntimeIds = paletteType & 1 + + if (!usingNetworkRuntimeIds && (format === StorageType.Runtime)) { + throw new Error(`Expected network encoding while decoding SubChunk at y=${this.y}`) + } + + const bitsPerBlock = paletteType >> 1 + this.loadPalettedBlocks(i, stream, bitsPerBlock, format) + } + } + + loadPalettedBlocks (storageLayer, stream, bitsPerBlock, format) { + const storage = new PalettedStorage(bitsPerBlock) + storage.read(stream) + this.blocks[storageLayer] = storage + + const paletteSize = format === StorageType.LocalPersistence ? stream.readUInt32LE() : stream.readZigZagVarInt() + if (paletteSize > stream.buffer.length || paletteSize < 1) throw new Error(`Invalid palette size: ${paletteSize}`) + + if (format === StorageType.Runtime) { + this.loadRuntimePalette(storageLayer, stream, paletteSize) + } else { + // Either "network persistent" (network with caching on <1.18) or local disk + this.loadLocalPalette(storageLayer, stream, paletteSize, format === StorageType.NetworkPersistence) + } + + this.blocks[storageLayer].incrementPalette(this.palette[storageLayer]) + } + + loadRuntimePalette (storageLayer, stream, paletteSize) { + this.palette[storageLayer] = [] + + for (let i = 0; i < paletteSize; i++) { + const stateId = stream.readZigZagVarInt() + const block = this.registry.blocksByStateId[stateId] + this.palette[storageLayer][i] = { stateId: stateId, ...block, count: 0 } + } + } + + loadLocalPalette (storageLayer, stream, paletteSize, overNetwork) { + this.palette[storageLayer] = [] + const buf = stream.buffer + let startOffset = stream.readOffset + let i + for (i = 0; i < paletteSize; i++) { + const { metadata, data } = nbt.protos[overNetwork ? 'littleVarint' : 'little'].parsePacketBuffer('nbt', buf, startOffset) + stream.readOffset += metadata.size // BinaryStream + startOffset += metadata.size // Buffer + + const { name, states, version } = nbt.simplify(data) + + let block = this.Block.fromProperties(name.replace('minecraft:', ''), states ?? {}, 0) + + if (!block) { + // This is not a valid block + debugger // eslint-disable-line + block = this.Block.fromProperties('air', {}, 0) + } + + this.palette[storageLayer][i] = { stateId: block.stateId, name, states: data.value.states.value, version, count: 0 } + } + + if (i !== paletteSize) { + throw new Error(`Expected ${paletteSize} blocks, got ${i}`) + } + } + + async encode (format, checksum = false, compact = true) { + const stream = new Stream() + + if (this.subChunkVersion >= 8) { + this.encodeV8(stream, format, compact) + } else { + throw new Error('Unsupported sub chunk version') + } + + const buf = stream.getBuffer() + if (checksum) { + this.hash = await getChecksum(buf) + this.updated = false + } + return buf + } + + // Encode sub chunk version 8+ + encodeV8 (stream, format, compact) { + stream.writeUInt8(this.subChunkVersion) + stream.writeUInt8(this.blocks.length) + if (this.subChunkVersion >= 9) { // Caves and cliffs (1.17-1.18) + stream.writeInt8(this.y) + } + for (let l = 0; l < this.blocks.length; l++) { + if (compact) this.compact(l) // Compact before encoding + this.writeStorage(stream, l, format) + } + } + + writeStorage (stream, storageLayer, format) { + const storage = this.blocks[storageLayer] + let paletteType = storage.bitsPerBlock << 1 + if (format === StorageType.Runtime) { + paletteType |= 1 + } + stream.writeUInt8(paletteType) + storage.write(stream) + + if (format === StorageType.LocalPersistence) { + stream.writeUInt32LE(this.palette[storageLayer].length) + } else { + stream.writeZigZagVarInt(this.palette[storageLayer].length) + } + + if (format === StorageType.Runtime) { + for (const block of this.palette[storageLayer]) { + stream.writeZigZagVarInt(block.stateId) + } + } else { + for (const block of this.palette[storageLayer]) { + const { name, states, version } = block + const tag = nbt.comp({ name: nbt.string(name), states: nbt.comp(states), version: nbt.int(version) }) + const buf = nbt.writeUncompressed(tag, format === StorageType.LocalPersistence ? 'little' : 'littleVarint') + stream.writeBuffer(buf) + } + } + } + + // Normal block access + + getBlock (l, x, y, z, biomeId) { + if (l !== undefined) { + const stateId = this.getBlockStateId(l, x, y, z) + return this.Block.fromStateId(stateId, biomeId) + } else { + const layer1 = this.getBlockStateId(0, x, y, z) + const layer2 = this.getBlockStateId(1, x, y, z) + const block = this.Block.fromStateId(layer1, biomeId) + if (layer2) { + block.superimposed = this.Block.fromStateId(layer2, biomeId) + const name = block.superimposed.name + // TODO: Snowy blocks have to be handled in prismarine-viewer + if (name.includes('water')) { + block.computedStates.waterlogged = true + } + } + return block + } + } + + setBlock (l, x, y, z, block) { + if (l !== undefined) { + this.setBlockStateId(l, x, y, z, block.stateId) + } else { + this.setBlockStateId(0, x, y, z, block.stateId) + if (block.superimposed) { + this.setBlockStateId(1, x, y, z, block.superimposed.stateId) + } + } + this.updated = true + } + + getBlockStateId (l = 0, x, y, z) { + const blocks = this.blocks[l] + if (!blocks) { + return this.registry.blocksByName.air.defaultState + } + return this.palette[l][blocks.get(x, y, z)].stateId + } + + setBlockStateId (l = 0, x, y, z, stateId) { + if (!this.palette[l]) { + this.palette[l] = [] + this.blocks[l] = new PalettedStorage(4) // Zero initialized + this.addToPalette(l, this.registry.blocksByName.air.defaultState, 4096 - 1) + this.addToPalette(l, stateId, 1) + this.blocks[l].set(x, y, z, this.palette[l].length - 1) + } else { + // Decrement count of old block + const currentIndex = this.blocks[l].get(x, y, z) + const currentEntry = this.palette[l][currentIndex] + if (currentEntry.stateId === stateId) { + return // No change + } + currentEntry.count-- + + for (let i = 0; i < this.palette[l].length; i++) { + const entry = this.palette[l][i] + if (entry.stateId === stateId) { + entry.count = Math.max(entry.count, 0) + 1 + this.blocks[l].set(x, y, z, i) + return + } + } + + this.addToPalette(l, stateId, 1) + this.blocks[l].set(x, y, z, this.palette[l].length - 1) + } + this.updated = true + } + + addToPalette (l, stateId, count = 0) { + const block = this.registry.blocksByStateId[stateId] + this.palette[l].push({ stateId, name: block.name, states: block.states, count }) + const minBits = neededBits(this.palette[l].length - 1) + if (minBits > this.blocks[l].bitsPerBlock) { + this.blocks[l] = this.blocks[l].resize(minBits) + } + } + + // These compation functions reduces the size of the chunk by removing unused blocks + // and reordering the palette to reduce the number of bits per block + isCompactable (layer) { + let newPaletteLength = 0 + for (const block of this.palette[layer]) { + if (block.count > 0) { + newPaletteLength++ + } + } + return newPaletteLength < this.palette[layer].length + } + + compact (layer) { + const newPalette = [] + const map = [] + for (const block of this.palette[layer]) { + if (block.count > 0) { + newPalette.push(block) + } + map.push(newPalette.length - 1) + } + + if (newPalette.length === this.palette[layer].length) { + return + } + + const newStorage = new PalettedStorage(neededBits(newPalette.length - 1)) + for (let x = 0; x < 16; x++) { + for (let y = 0; y < 16; y++) { + for (let z = 0; z < 16; z++) { + const ix = this.blocks[layer].get(x, y, z) + newStorage.set(x, y, z, map[ix]) + } + } + } + this.blocks[layer] = newStorage + this.palette[layer] = newPalette + } + + /** + * Gets the block runtime ID at the layer and position + * @returns Global block palette (runtime) ID for the block + */ + getPaletteEntry (l, x, y, z) { + return this.palette[l][this.blocks[l].get(x, y, z)] + } + + getPalette (layer = 0) { + return this.palette[layer].filter(block => block.count > 0) + } + + // Lighting - Not written or read, but computed during chunk loading + getBlockLight (x, y, z) { + return this.blockLight.get(x, y, z) + } + + setBlockLight (x, y, z, value) { + this.blockLight.set(x, y, z, value) + } + + getSkyLight (x, y, z) { + return this.skyLight.get(x, y, z) + } + + setSkyLight (x, y, z, value) { + this.skyLight.set(x, y, z, value) + } + + toObject () { + return { + y: this.y, + palette: this.palette, + blocks: this.blocks, + subChunkVersion: this.subChunkVersion, + hash: this.hash, + updated: this.updated, + + blockLight: this.blockLight, + skyLight: this.skyLight + } + } +} + +module.exports = SubChunk diff --git a/bridge/lib/prismarine-chunk/src/bedrock/1.3/chunk.js b/bridge/lib/prismarine-chunk/src/bedrock/1.3/chunk.js new file mode 100644 index 0000000..d061ddd --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/1.3/chunk.js @@ -0,0 +1,20 @@ +const { ChunkVersion } = require('../common/constants') +const ChunkColumn = require('./ChunkColumn') + +module.exports = (version) => { + // Require once here to avoid requiring() on every new chunk instance + const registry = version.blockRegistry || version + const Block = require('prismarine-block')(registry) + const Biome = require('prismarine-biome')(registry) + return class Chunk extends ChunkColumn { + constructor (options) { + super(options, registry, Block, Biome) + this.chunkVersion = this.chunkVersion || ChunkVersion.v1_16_0 + this.subChunkVersion = 8 + } + + static fromJson (str) { + return new this(JSON.parse(str)) + } + } +} diff --git a/bridge/lib/prismarine-chunk/src/bedrock/common/BlobCache.js b/bridge/lib/prismarine-chunk/src/bedrock/common/BlobCache.js new file mode 100644 index 0000000..fe81e25 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/common/BlobCache.js @@ -0,0 +1,17 @@ +// A type ID signifying the type of the blob. +const BlobType = { + ChunkSection: 0, + Biomes: 1 +} + +class BlobEntry { + // When the blob was put into BlobCache store + created = Date.now() + // The type of blob + type + constructor (args) { + Object.assign(this, args) + } +} + +module.exports = { BlobType, BlobEntry } diff --git a/bridge/lib/prismarine-chunk/src/bedrock/common/CommonChunkColumn.js b/bridge/lib/prismarine-chunk/src/bedrock/common/CommonChunkColumn.js new file mode 100644 index 0000000..4084d74 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/common/CommonChunkColumn.js @@ -0,0 +1,265 @@ +const { Vec3 } = require('vec3') +const Stream = require('../common/Stream') +const nbt = require('prismarine-nbt') + +const keyFromLocalPos = pos => `${pos.x},${pos.y},${pos.z}` +const keyFromGlobalPos = (x, y, z) => `${x & 0xf},${y},${z & 0xf}` + +class CommonChunkColumn { + constructor (options, registry) { + this.x = options.x || 0 + this.z = options.z || 0 + this.registry = registry + this.chunkVersion = options.chunkVersion + this.blockEntities = options.blockEntities || {} + this.sections = [] + this.entities = {} + // TODO: this can be defaulted to true + this.compactOnSave = false + this.setBounds(0, 16) + } + + setBounds (minCY, maxCY) { + this.minCY = minCY + this.maxCY = maxCY + this.maxY = this.maxCY * 16 + this.minY = this.minCY * 16 + this.worldHeight = this.maxY + Math.abs(this.minY) + this.co = Math.abs(this.minCY) + } + + initialize (func) { + const p = new Vec3() + for (p.y = 0; p.y < this.worldHeight; p.y++) { + for (p.z = 0; p.z < 16; p.z++) { + for (p.x = 0; p.x < 16; p.x++) { + const block = func(p.x, p.y, p.z) + if (block) this.setBlock(p, block) + } + } + } + } + + // Blocks + + getBlock (vec4, full = true) { + const Y = vec4.y >> 4 + const sec = this.sections[this.co + Y] + if (!sec) return this.Block.fromStateId(this.registry.blocksByName.air.defaultState, 0) + const block = sec.getBlock(vec4.l, vec4.x, vec4.y & 0xf, vec4.z, this.getBiomeId(vec4)) + if (full) { + block.light = sec.blockLight.get(vec4.x, vec4.y & 0xf, vec4.z) + block.skyLight = sec.skyLight.get(vec4.x, vec4.y & 0xf, vec4.z) + block.entity = this.getBlockEntity(vec4) + } + return block + } + + setBlock (pos, block) { + const Y = pos.y >> 4 + let sec = this.sections[this.co + Y] + if (!sec) { + sec = new this.Section(this.registry, this.Block, { y: Y, subChunkVersion: this.subChunkVersion }) + this.sections[this.co + Y] = sec + } + sec.setBlock(pos.l, pos.x, pos.y & 0xf, pos.z, block) + if (block.light !== undefined) sec.blockLight.set(pos.x, pos.y & 0xf, pos.z, block.light) + if (block.skyLight !== undefined) sec.skyLight.set(pos.x, pos.y & 0xf, pos.z, block.skyLight) + if (block.entity) this.setBlockEntity(pos, block.entity) + } + + getBlockStateId (pos) { + const Y = pos.y >> 4 + const sec = this.sections[this.co + Y] + if (!sec) { return } + return sec.getBlockStateId(pos.l, pos.x, pos.y & 0xf, pos.z) + } + + setBlockStateId (pos, stateId) { + const Y = pos.y >> 4 + let sec = this.sections[this.co + Y] + if (!sec) { + sec = new this.Section(this.registry, this.Block, { y: Y, subChunkVersion: this.subChunkVersion }) + this.sections[this.co + Y] = sec + } + sec.setBlockStateId(pos.l, pos.x, pos.y & 0xf, pos.z, stateId) + } + + getBiomeId (pos) { + return 0 + } + + setBiomeId (pos, biomeId) { + // noop + } + + getBlocks () { + const arr = this.sections.map(sec => sec.getPalette()).flat(2) + const deduped = Object.values(arr.reduce((acc, block) => { + if (!acc[block.stateId]) acc[block.stateId] = block + acc[block.stateId] = block + return acc + }, {})) + return deduped + } + + // Lighting + setBlockLight (pos, light) { + this.sections[this.co + (pos.y >> 4)].blockLight.set(pos.x, pos.y & 0xf, pos.z, light) + } + + setSkyLight (pos, light) { + this.sections[this.co + (pos.y >> 4)].skyLight.set(pos.x, pos.y & 0xf, pos.z, light) + } + + getSkyLight (pos) { + return this.sections[this.co + (pos.y >> 4)].skyLight.get(pos.x, pos.y & 0xf, pos.z) + } + + getBlockLight (pos) { + return this.sections[this.co + (pos.y >> 4)].blockLight.get(pos.x, pos.y & 0xf, pos.z) + } + + // Block entities + + setBlockEntity (pos, tag) { + this.blockEntities[keyFromLocalPos(pos)] = tag + } + + getBlockEntity (pos) { + return this.blockEntities[keyFromLocalPos(pos)] + } + + addBlockEntity (tag) { + const lPos = keyFromGlobalPos(tag.value.x.value, tag.value.y.value, tag.value.z.value) + this.blockEntities[lPos] = tag + } + + removeBlockEntity (pos) { + delete this.blockEntities[keyFromLocalPos(pos)] + } + + // This is only capable of moving block entities within the same chunk ... prismarine-world should implement this + moveBlockEntity (pos, newPos) { + const oldKey = keyFromLocalPos(pos) + const newKey = keyFromLocalPos(newPos) + const tag = this.blockEntities[oldKey] + delete this.blockEntities[oldKey] + this.blockEntities[newKey] = tag + } + + // Entities + addEntity (entityTag) { + const key = entityTag.value.UniqueID.value.toString() + this.entities[key] = entityTag + } + + removeEntity (id) { + delete this.entities[id] + } + + // Section management + + getSection ({ y }) { + return this.sections[this.co + (y >> 4)] + } + + getSectionAtIndex (y) { + return this.sections[this.co + y] + } + + setSection (y, section) { + this.sections[this.co + y] = section + } + + newSection (y, storageFormat, buffer) { + if (storageFormat === undefined) { + const n = this.Section.create(this.registry, this.Block, { y, subChunkVersion: this.subChunkVersion }) + this.setSection(y, n) + return n + } else { + const n = new this.Section(this.registry, this.Block, { y, subChunkVersion: this.subChunkVersion }) + n.decode(storageFormat, buffer) + this.setSection(y, n) + return n + } + } + + getSectionBlockEntities (sectionY) { + const found = [] + for (const key in this.blockEntities) { + const y = parseInt(key.split(',')[1]) >> 4 + if (y === sectionY) { + found.push(this.blockEntities[key]) + } + } + return found + } + + getSections () { + return this.sections + } + + // Entities + + getEntities () { + return this.entities + } + + loadEntities (tags) { + this.entities = tags + } + + // Disk Encoding + // Similar to network encoding, except without varints + + diskEncodeBlockEntities () { + const tileBufs = [] + for (const key in this.blockEntities) { + const tile = this.blockEntities[key] + tileBufs.push(nbt.writeUncompressed(tile, 'little')) + } + return Buffer.concat(tileBufs) + } + + diskEncodeEntities () { + const entityBufs = [] + for (const entity in this.entities) { + const tag = this.entities[entity] + entityBufs.push(nbt.writeUncompressed(tag, 'little')) + } + return Buffer.concat(entityBufs) + } + + diskDecodeBlockEntities (buffer) { + if (!buffer) return + const stream = buffer instanceof Buffer ? new Stream(buffer) : buffer + let startOffset = stream.readOffset + while (stream.peek() === 0x0A) { + const { data, metadata } = nbt.protos.little.parsePacketBuffer('nbt', buffer, startOffset) + stream.readOffset += metadata.size + startOffset += metadata.size + this.addBlockEntity(data) + } + } + + diskDecodeEntities (buffer) { + if (!buffer) return + const stream = buffer instanceof Buffer ? new Stream(buffer) : buffer + let startOffset = stream.readOffset + while (stream.peek() === 0x0A) { + const { data, metadata } = nbt.protos.little.parsePacketBuffer('nbt', buffer, startOffset) + stream.readOffset += metadata.size + startOffset += metadata.size + this.addEntity(data) + } + } + + toObject () { + const sections = this.sections.map(sec => sec?.toObject()) + const { x, z, chunkVersion, blockEntities } = this + return { x, z, chunkVersion, blockEntities, sections } + } +} + +module.exports = CommonChunkColumn diff --git a/bridge/lib/prismarine-chunk/src/bedrock/common/PalettedStorage.js b/bridge/lib/prismarine-chunk/src/bedrock/common/PalettedStorage.js new file mode 100644 index 0000000..b381011 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/common/PalettedStorage.js @@ -0,0 +1,106 @@ +const wordByteSize = 4 +const wordBitSize = wordByteSize * 8 +const storageSize = 4096 // 4096 -> total # of entities (e.g. blocks) in storage, 16^3 + +class BetterUint32Array extends Uint32Array { + toJSON () { + return Array.from(this) + } + + [Symbol.for('nodejs.util.inspect.custom')] () { + return `[Uint32Array of ${this.length} items { ${this.slice(0, 10).toString()} ${this.length > 10 ? ', ...' : ''} }]` + } +} + +class PalettedStorage { + constructor (bitsPerBlock) { + this.bitsPerBlock = bitsPerBlock + this.blocksPerWord = Math.floor(wordBitSize / bitsPerBlock) + this.wordsCount = Math.ceil(storageSize / this.blocksPerWord) + this.mask = ((1 << bitsPerBlock) - 1) + this.array = new BetterUint32Array(this.wordsCount) + } + + read (stream) { + const buf = stream.readBuffer(this.wordsCount * wordByteSize) + this.array = new BetterUint32Array(new Uint8Array(buf).buffer) + } + + write (stream) { + stream.writeBuffer(Buffer.from(this.array.buffer)) + } + + static copyFrom (other) { + const next = new PalettedStorage(other.bitsPerBlock) + Object.assign(next, other) + if (other instanceof Uint32Array) { + next.array = new BetterUint32Array(other.array.slice()) + } else { + next.array = new BetterUint32Array(other.array) + } + return next + } + + getBuffer () { + return Buffer.from(this.array.buffer) + } + + readBits (index, offset) { + return (this.array[index] >> offset) & this.mask + } + + writeBits (index, offset, data) { + this.array[index] &= ~(this.mask << offset) + this.array[index] |= (data & this.mask) << offset + } + + getIndex (x, y, z) { + x &= 0xf + y &= 0xf + z &= 0xf + const index = Math.floor(((x << 8) | (z << 4) | y) / this.blocksPerWord) + const offset = (((x << 8) | (z << 4) | y) % this.blocksPerWord) * this.bitsPerBlock + return [index, offset] + } + + get (x, y, z) { + const [index, offset] = this.getIndex(x, y, z) + return this.readBits(index, offset) + } + + set (x, y, z, data) { + const [index, offset] = this.getIndex(x, y, z) + this.writeBits(index, offset, data) + } + + resize (newBitsPerBlock) { + const storage = new PalettedStorage(newBitsPerBlock) + for (let x = 0; x < 16; x++) { + for (let y = 0; y < 16; y++) { + for (let z = 0; z < 16; z++) { + storage.set(x, y, z, this.get(x, y, z)) + } + } + } + return storage + } + + forEach (callback) { + for (let i = 0; i < storageSize; i++) { + const index = Math.floor(i / this.blocksPerWord) + const offset = (i % this.blocksPerWord) * this.bitsPerBlock + callback(this.readBits(index, offset)) + } + } + + incrementPalette (palette) { + for (let i = 0; i < storageSize; i++) { + const index = Math.floor(i / this.blocksPerWord) + const offset = (i % this.blocksPerWord) * this.bitsPerBlock + const ix = this.readBits(index, offset) + palette[ix].count++ + } + } +} + +module.exports = PalettedStorage diff --git a/bridge/lib/prismarine-chunk/src/bedrock/common/Stream.js b/bridge/lib/prismarine-chunk/src/bedrock/common/Stream.js new file mode 100644 index 0000000..901114b --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/common/Stream.js @@ -0,0 +1,369 @@ +const MAX_ALLOC_SIZE = 1024 * 1024 * 2 // 2MB +const DEFAULT_ALLOC_SIZE = 10000 + +class ByteStream { + constructor (buffer) { + this.buffer = buffer || Buffer.allocUnsafe(DEFAULT_ALLOC_SIZE) + this.readOffset = 0 + this.writeOffset = 0 + this.size = this.buffer.length + } + + resizeForWriteIfNeeded (bytes) { + if ((this.writeOffset + bytes) > this.size) { + this.size *= 2 + const allocSize = this.size - this.writeOffset + this.buffer = Buffer.concat([this.buffer, Buffer.allocUnsafe(allocSize)]) + } + // Detect potential writing bugs + if (this.size > MAX_ALLOC_SIZE) throw new Error('Buffer size exceeded guard limit') + } + + readByte () { + return this.buffer[this.readOffset++] + } + + // Write unsigned + + writeUInt8 (value) { + this.resizeForWriteIfNeeded(1) + this.buffer.writeUInt8(value, this.writeOffset) + this.writeOffset += 1 + } + + writeUInt16LE (value) { + this.resizeForWriteIfNeeded(2) + this.buffer.writeUInt16LE(value, this.writeOffset) + this.writeOffset += 2 + } + + writeUInt32LE (value) { + this.resizeForWriteIfNeeded(4) + this.buffer.writeUInt32LE(value, this.writeOffset) + this.writeOffset += 4 + } + + writeUInt64LE (value) { + this.resizeForWriteIfNeeded(8) + switch (typeof value) { + case 'bigint': + this.buffer.writeBigUInt64LE(value, this.writeOffset) + break + case 'number': + this.buffer.writeUInt32LE(value & 0xffffffff, this.writeOffset) + this.buffer.writeUInt32LE(value >>> 32, this.writeOffset + 4) + break + default: + throw new Error('Invalid value type') + } + + this.writeOffset += 8 + } + + writeFloatLE (value) { + this.resizeForWriteIfNeeded(4) + this.buffer.writeFloatLE(value, this.writeOffset) + this.writeOffset += 4 + } + + writeDoubleLE (value) { + this.resizeForWriteIfNeeded(8) + this.buffer.writeDoubleLE(value, this.writeOffset) + this.writeOffset += 8 + } + + writeUInt16BE (value) { + this.resizeForWriteIfNeeded(2) + this.buffer.writeUInt16BE(value, this.writeOffset) + this.writeOffset += 2 + } + + writeUInt32BE (value) { + this.resizeForWriteIfNeeded(4) + this.buffer.writeUInt32BE(value, this.writeOffset) + this.writeOffset += 4 + } + + writeUInt64BE (value) { + this.resizeForWriteIfNeeded(8) + switch (typeof value) { + case 'bigint': + this.buffer.writeBigUInt64BE(value, this.writeOffset) + break + case 'number': + this.buffer.writeUInt32BE(value >>> 32, this.writeOffset) + this.buffer.writeUInt32BE(value & 0xffffffff, this.writeOffset + 4) + break + default: + throw new Error('Invalid value type') + } + + this.writeOffset += 8 + } + + // Write signed + + writeInt8 (value) { + this.resizeForWriteIfNeeded(1) + this.buffer.writeInt8(value, this.writeOffset) + this.writeOffset += 1 + } + + writeInt16LE (value) { + this.resizeForWriteIfNeeded(2) + this.buffer.writeInt16LE(value, this.writeOffset) + this.writeOffset += 2 + } + + writeInt32LE (value) { + this.resizeForWriteIfNeeded(4) + this.buffer.writeInt32LE(value, this.writeOffset) + this.writeOffset += 4 + } + + writeInt64LE (value) { + this.resizeForWriteIfNeeded(8) + switch (typeof value) { + case 'bigint': + this.buffer.writeBigInt64LE(value, this.writeOffset) + break + case 'number': + this.buffer.writeInt32LE(value & 0xffffffff, this.writeOffset) + this.buffer.writeInt32LE(value >>> 32, this.writeOffset + 4) + break + default: + throw new Error('Invalid value type') + } + + this.writeOffset += 8 + } + + writeInt16BE (value) { + this.resizeForWriteIfNeeded(2) + this.buffer.writeInt16BE(value, this.writeOffset) + this.writeOffset += 2 + } + + writeInt32BE (value) { + this.resizeForWriteIfNeeded(4) + this.buffer.writeInt32BE(value, this.writeOffset) + this.writeOffset += 4 + } + + writeInt64BE (value) { + this.resizeForWriteIfNeeded(8) + switch (typeof value) { + case 'bigint': + this.buffer.writeBigInt64BE(value, this.writeOffset) + break + case 'number': + this.buffer.writeInt32BE(value >>> 32, this.writeOffset) + this.buffer.writeInt32BE(value & 0xffffffff, this.writeOffset + 4) + break + default: + throw new Error('Invalid value type') + } + + this.writeOffset += 8 + } + + // Write floats + + writeFloatBE (value) { + this.resizeForWriteIfNeeded(4) + this.buffer.writeFloatBE(value, this.writeOffset) + this.writeOffset += 4 + } + + writeDoubleBE (value) { + this.resizeForWriteIfNeeded(8) + this.buffer.writeDoubleBE(value, this.writeOffset) + this.writeOffset += 8 + } + + // Read + readUInt8 () { + const value = this.buffer.readUInt8(this.readOffset) + this.readOffset += 1 + return value + } + + readUInt16LE () { + const value = this.buffer.readUInt16LE(this.readOffset) + this.readOffset += 2 + return value + } + + readUInt32LE () { + const value = this.buffer.readUInt32LE(this.readOffset) + this.readOffset += 4 + return value + } + + readUInt64LE () { + const value = this.buffer.readBigUInt64LE(this.readOffset) + this.readOffset += 8 + return value + } + + readUInt16BE () { + const value = this.buffer.readUInt16BE(this.readOffset) + this.readOffset += 2 + return value + } + + readUInt32BE () { + const value = this.buffer.readUInt32BE(this.readOffset) + this.readOffset += 4 + return value + } + + readUInt64BE () { + const value = this.buffer.readBigUInt64BE(this.readOffset) + this.readOffset += 8 + return value + } + + readInt8 () { + const value = this.buffer.readInt8(this.readOffset) + this.readOffset += 1 + return value + } + + readInt16LE () { + const value = this.buffer.readInt16LE(this.readOffset) + this.readOffset += 2 + return value + } + + readInt32LE () { + const value = this.buffer.readInt32LE(this.readOffset) + this.readOffset += 4 + return value + } + + readInt64LE () { + const value = this.buffer.readBigInt64LE(this.readOffset) + this.readOffset += 8 + return value + } + + // Strings + writeStringNT (value, encoding = 'utf8') { + this.resizeForWriteIfNeeded(value.length + 1) + this.buffer.write(value, this.writeOffset, value.length, encoding) + this.buffer[this.writeOffset + value.length] = 0 // Null terminator + this.writeOffset += value.length + 1 + } + + writeStringRaw (value, encoding = 'utf8') { + this.resizeForWriteIfNeeded(value.length) + this.buffer.write(value, this.writeOffset, value.length, encoding) + this.writeOffset += value.length + } + + writeBuffer (value) { + this.resizeForWriteIfNeeded(value.length) + value.copy(this.buffer, this.writeOffset) + this.writeOffset += value.length + } + + readBuffer (length) { + const value = this.buffer.slice(this.readOffset, this.readOffset + length) + this.readOffset += length + return value + } + + // Varints + writeVarInt (value) { + this.resizeForWriteIfNeeded(9) + let offset = 0 + + do { + let tempByte = value & 0x7f + value >>>= 7 + + if (value !== 0) { + tempByte |= 0x80 + } + + this.buffer[this.writeOffset + offset] = tempByte + offset++ + } while (value !== 0) + this.writeOffset += offset + } + + readVarInt () { + let value = 0 + let offset = 0 + let byte + do { + byte = this.buffer[this.readOffset + offset] + value |= (byte & 0x7f) << (7 * offset) + offset += 1 + } while (byte & 0x80) + this.readOffset += offset + return value + } + + writeVarLong (value) { + this.resizeForWriteIfNeeded(9) + let offset = 0 + while (value >= 0x80n) { + this.buffer[this.writeOffset + offset] = (value & 0x7fn) | 0x80n + value = value >>> 7n + offset += 1 + } + this.buffer[this.writeOffset + offset] = value + this.writeOffset += offset + 1 + } + + readVarLong () { + let value = 0n + let offset = 0n + let byte + do { + byte = this.buffer[this.readOffset + offset] + value |= (byte & 0x7fn) << (7n * offset) + offset += 1n + } while (byte & 0x80n) + this.readOffset += Number(offset) + return value + } + + writeZigZagVarInt (value) { + const zigzag = (value << 1) ^ (value >> 31) + this.writeVarInt(zigzag) + } + + readZigZagVarInt () { + const value = this.readVarInt() + return (value >>> 1) ^ -(value & 1) + } + + writeZigZagVarlong (value) { + const zigzag = (value << 1n) ^ (value >> 63n) + this.writeVarlong(zigzag) + } + + readZigZagVarlong () { + const value = this.readVarInt() + return (value >>> 1n) ^ -(value & 1n) + } + + // Extra + + peekUInt8 () { + return this.buffer[this.readOffset] + } + + peek () { + return this.buffer[this.readOffset] + } + + getBuffer () { + return this.buffer.slice(0, this.writeOffset) + } +} + +module.exports = ByteStream diff --git a/bridge/lib/prismarine-chunk/src/bedrock/common/constants.js b/bridge/lib/prismarine-chunk/src/bedrock/common/constants.js new file mode 100644 index 0000000..de6d3a3 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/common/constants.js @@ -0,0 +1,42 @@ +module.exports = { + StorageType: { + LocalPersistence: 0, + NetworkPersistence: 1, + Runtime: 2 + }, + + ChunkVersion: { + v0_9_00: 0, + v0_9_02: 1, // added to fix the grass color being corrupted + v0_9_05: 2, // make sure that biomes are not corrupted + v0_17_0: 3, // switch to a key per subchunk + 2D data + v0_18_0: 4, // made beds be block entities + vConsole1_to_v0_18_0: 5, // converted from another version of the game + v1_2_0: 6, // Format added in MC1.2 - for upgrading structure spawners + v1_2_0_bis: 7, // second format added in MC1.2 - to remove dynamic water in oceans + v1_4_0: 8, + v1_8_0: 9, + v1_9_0: 10, + v1_10_0: 11, + v1_11_0: 12, + v1_11_1: 13, + v1_11_2: 14, + v1_12_0: 15, + v1_15_0: 16, + v1_15_1: 17, + v1_16_0: 18, + v1_16_1: 19, + v1_16_100: 20, + v1_16_200: 21, + v1_16_210: 22, // caves and cliffs disabled + + v1_17_0: 25, // 1.17.0-20 caves and cliffs enabled + + v1_17_30: 29, // 1.17.30 caves and cliffs enabled + + v1_17_40: 31, + + v1_18_0: 39, + v1_18_11: 39 + } +} diff --git a/bridge/lib/prismarine-chunk/src/bedrock/common/util.js b/bridge/lib/prismarine-chunk/src/bedrock/common/util.js new file mode 100644 index 0000000..1b9055f --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/bedrock/common/util.js @@ -0,0 +1,16 @@ +const xxhash = require('xxhash-wasm') +let hasher +module.exports = { + async getChecksum (buffer) { + if (!hasher) { + hasher = await xxhash() + } + // with node 16, below would work + // return hasher.h64Raw(buffer) + // with node 14, no i64 wasm interface, need to read two u32s from Uint8Array + const hash = hasher.h64Raw(buffer) + const hi = BigInt((hash[0]) | ((hash[1]) << 8) | ((hash[2]) << 16) | ((hash[3]) << 24)) + const lo = BigInt((hash[4]) | ((hash[5]) << 8) | ((hash[6]) << 16) | ((hash[7]) << 24)) + return BigInt.asUintN(32, hi) << 32n | BigInt.asUintN(32, lo) + } +} diff --git a/bridge/lib/prismarine-chunk/src/index.js b/bridge/lib/prismarine-chunk/src/index.js new file mode 100644 index 0000000..45ac989 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/index.js @@ -0,0 +1,54 @@ +const chunkImplementations = { + pc: { + 1.8: require('./pc/1.8/chunk'), + 1.9: require('./pc/1.9/chunk'), + '1.10': require('./pc/1.9/chunk'), + 1.11: require('./pc/1.9/chunk'), + 1.12: require('./pc/1.9/chunk'), + 1.13: require('./pc/1.13/chunk'), + 1.14: require('./pc/1.14/chunk'), + 1.15: require('./pc/1.15/chunk'), + 1.16: require('./pc/1.16/chunk'), + 1.17: require('./pc/1.17/chunk'), + 1.18: require('./pc/1.18/chunk'), + 1.19: require('./pc/1.18/chunk'), + '1.20': require('./pc/1.18/chunk'), + 1.21: require('./pc/1.18/chunk') + }, + bedrock: { + 0.14: require('./bedrock/0.14/chunk'), + '1.0': require('./bedrock/1.0/chunk'), + 1.3: require('./bedrock/1.3/chunk'), + 1.16: require('./bedrock/1.3/chunk'), + 1.17: require('./bedrock/1.3/chunk'), + 1.18: require('./bedrock/1.18/chunk'), + 1.19: require('./bedrock/1.18/chunk'), + '1.20': require('./bedrock/1.18/chunk'), + 1.21: require('./bedrock/1.18/chunk'), + '26.10': require('./bedrock/1.18/chunk'), + 1.26: require('./bedrock/1.18/chunk') + } +} + +module.exports = loader +// Caching +const blobCache = require('./bedrock/common/BlobCache') +module.exports.BlobEntry = blobCache.BlobEntry +module.exports.BlobType = blobCache.BlobType + +function loader (registryOrVersion) { + const registry = typeof registryOrVersion === 'string' ? require('prismarine-registry')(registryOrVersion) : registryOrVersion + const version = registry.version + if (!version) throw new Error('Specified version does not exist') + try { + return chunkImplementations[version.type][version.majorVersion](registry) + } catch (e) { + if (e instanceof TypeError) { + console.error(e) + throw new Error(`[Prismarine-chunk] No chunk implementation for ${version?.type} ${version?.majorVersion} found`) + } else { + console.log(`Error while loading ${version.type} - ${version.majorVersion}`) + throw e + } + } +} diff --git a/bridge/lib/prismarine-chunk/src/pc/1.13/ChunkColumn.js b/bridge/lib/prismarine-chunk/src/pc/1.13/ChunkColumn.js new file mode 100644 index 0000000..1dd0900 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.13/ChunkColumn.js @@ -0,0 +1,288 @@ +const SmartBuffer = require('smart-buffer').SmartBuffer +const ChunkSection = require('./ChunkSection') +const constants = require('../common/constants') +const BitArray = require('../common/BitArray') +const varInt = require('../common/varInt') +const CommonChunkColumn = require('../common/CommonChunkColumn') +const neededBits = require('../common/neededBits') + +// wrap with func to provide version specific Block +module.exports = (Block, mcData) => { + return class ChunkColumn extends CommonChunkColumn { + static get section () { return ChunkSection } + constructor () { + super(mcData) + this.sectionMask = 0 + this.skyLightSent = true + this.sections = Array(constants.NUM_SECTIONS).fill(null) + this.biomes = Array( + constants.SECTION_WIDTH * constants.SECTION_WIDTH + ).fill(1) + this.maxBitsPerBlock = neededBits(Object.values(mcData.blocks).reduce((high, block) => Math.max(high, block.maxStateId), 0)) + } + + toJson () { + return JSON.stringify({ + biomes: this.biomes, + blockEntities: this.blockEntities, + sectionMask: this.sectionMask, + sections: this.sections.map(section => section === null ? null : section.toJson()) + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new ChunkColumn() + chunk.biomes = parsed.biomes + chunk.blockEntities = parsed.blockEntities + chunk.sectionMask = parsed.sectionMask + chunk.sections = parsed.sections.map(s => s === null ? null : ChunkSection.fromJson(s)) + return chunk + } + + initialize (func) { + const p = { x: 0, y: 0, z: 0 } + for (p.y = 0; p.y < constants.CHUNK_HEIGHT; p.y++) { + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + const block = func(p.x, p.y, p.z) + if (block === null) { + continue + } + this.setBlock(p, block) + } + } + } + } + + getBlock (pos) { + const section = this.sections[pos.y >> 4] + const biome = this.getBiome(pos) + if (!section) { + return Block.fromStateId(0, biome) + } + const stateId = section.getBlock(toSectionPos(pos)) + const block = Block.fromStateId(stateId, biome) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + block.entity = this.getBlockEntity(pos) + return block + } + + setBlock (pos, block) { + if (typeof block.stateId !== 'undefined') { + this.setBlockStateId(pos, block.stateId) + } + if (typeof block.biome !== 'undefined') { + this.setBiome(pos, block.biome.id) + } + if (typeof block.skyLight !== 'undefined' && this.skyLightSent) { + this.setSkyLight(pos, block.skyLight) + } + if (typeof block.light !== 'undefined') { + this.setBlockLight(pos, block.light) + } + if (block.entity) { + this.setBlockEntity(pos, block.entity) + } else { + this.removeBlockEntity(pos) + } + } + + getBlockType (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].id + } + + getBlockData (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].metadata + } + + getBlockStateId (pos) { + const section = this.sections[pos.y >> 4] + return section ? section.getBlock(toSectionPos(pos)) : 0 + } + + getBlockLight (pos) { + const section = this.sections[pos.y >> 4] + return section ? section.getBlockLight(toSectionPos(pos)) : 15 + } + + getSkyLight (pos) { + const section = this.sections[pos.y >> 4] + return section ? section.getSkyLight(toSectionPos(pos)) : 15 + } + + getBiome (pos) { + return this.biomes[getBiomeIndex(pos)] + } + + getBiomeColor (pos) { + // TODO + return { r: 0, g: 0, b: 0 } + } + + setBlockType (pos, id) { + this.setBlockStateId(pos, mcData.blocks[id].minStateId) + } + + setBlockData (pos, data) { + this.setBlockStateId(pos, mcData.blocksByStateId[this.getBlockStateId(pos)].minStateId + data) + } + + setBlockStateId (pos, stateId) { + const sectionIndex = pos.y >> 4 + if (sectionIndex < 0 || sectionIndex >= 16) return + + let section = this.sections[sectionIndex] + if (!section) { + // if it's air + if (stateId === 0) { + return + } + section = new ChunkSection({ + maxBitsPerBlock: this.maxBitsPerBlock + }) + this.sectionMask |= 1 << sectionIndex + this.sections[sectionIndex] = section + } + + section.setBlock(toSectionPos(pos), stateId) + } + + setBlockLight (pos, light) { + const section = this.sections[pos.y >> 4] + return section && section.setBlockLight(toSectionPos(pos), light) + } + + setSkyLight (pos, light) { + const section = this.sections[pos.y >> 4] + return section && section.setSkyLight(toSectionPos(pos), light) + } + + setBiome (pos, biome) { + this.biomes[getBiomeIndex(pos)] = biome + } + + getMask () { + return this.sectionMask + } + + // These methods do nothing, and are present only for API compatibility + dumpBiomes () { + + } + + dumpLight () { + + } + + loadLight () { + + } + + loadBiomes () { + + } + + dump () { + const smartBuffer = new SmartBuffer() + this.sections.forEach((section, i) => { + if (section !== null && !section.isEmpty()) { + section.write(smartBuffer) + } + }) + + // write biome data + this.biomes.forEach(biome => { + smartBuffer.writeInt32BE(biome) + }) + + return smartBuffer.toBuffer() + } + + load (data, bitMap = 0xffff, skyLightSent = true, fullChunk = true) { + // make smartbuffer from node buffer + // so that we doesn't need to maintain a cursor + const reader = SmartBuffer.fromBuffer(data) + + this.skyLightSent = skyLightSent + this.sectionMask |= bitMap + for (let y = 0; y < constants.NUM_SECTIONS; ++y) { + // does `data` contain this chunk? + if (!((bitMap >> y) & 1)) { + // we can skip write a section if it isn't requested + continue + } + + // keep temporary palette + let palette + let skyLight + + // get number of bits a palette item use + const bitsPerBlock = reader.readUInt8() + + // check if the section uses a section palette + if (bitsPerBlock <= constants.MAX_BITS_PER_BLOCK) { + palette = [] + // get number of palette items + const numPaletteItems = varInt.read(reader) + + // save each palette item + for (let i = 0; i < numPaletteItems; ++i) { + palette.push(varInt.read(reader)) + } + } else { + // global palette is used + palette = null + } + + // number of items in data array + const dataArray = new BitArray({ + bitsPerValue: bitsPerBlock > constants.MAX_BITS_PER_BLOCK ? this.maxBitsPerBlock : bitsPerBlock, + capacity: 4096 + }).readBuffer(reader, varInt.read(reader) * 2) + + const blockLight = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + + if (skyLightSent) { + skyLight = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + } + + const section = new ChunkSection({ + data: dataArray, + palette, + blockLight, + maxBitsPerBlock: this.maxBitsPerBlock, + ...(skyLightSent ? { skyLight } : { skyLight: null }) + }) + this.sections[y] = section + } + + // read biomes + if (fullChunk) { + const p = { x: 0, y: 0, z: 0 } + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + this.setBiome(p, reader.readInt32BE()) + } + } + } + } + } +} + +function getBiomeIndex (pos) { + return (pos.z * 16) | pos.x +} + +function toSectionPos (pos) { + return { x: pos.x, y: pos.y & 15, z: pos.z } +} diff --git a/bridge/lib/prismarine-chunk/src/pc/1.13/ChunkSection.js b/bridge/lib/prismarine-chunk/src/pc/1.13/ChunkSection.js new file mode 100644 index 0000000..d9b5b87 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.13/ChunkSection.js @@ -0,0 +1,196 @@ +const BitArray = require('../common/BitArray') +const neededBits = require('../common/neededBits') +const constants = require('../common/constants') +const varInt = require('../common/varInt') +const GLOBAL_BITS_PER_BLOCK = 13 + +function getBlockIndex (pos) { + return (pos.y << 8) | (pos.z << 4) | pos.x +} + +class ChunkSection { + constructor (options = {}) { + if (options === null) { + return + } + + if (typeof options.solidBlockCount === 'undefined') { + options.solidBlockCount = 0 + if (options.data) { + for (let i = 0; i < constants.BLOCK_SECTION_VOLUME; i++) { + if (options.data.get(i) !== 0) { + options.solidBlockCount += 1 + } + } + } + } + + if (!options.data) { + options.data = new BitArray({ + bitsPerValue: 4, + capacity: constants.BLOCK_SECTION_VOLUME + }) + } + + if (options.palette === undefined) { // dont create palette if its null + options.palette = [0] + } + + if (!options.blockLight) { + options.blockLight = new BitArray({ + bitsPerValue: 4, + capacity: constants.BLOCK_SECTION_VOLUME + }) + } + + if (options.skyLight === undefined) { // dont create skylight if its null + options.skyLight = new BitArray({ + bitsPerValue: 4, + capacity: constants.BLOCK_SECTION_VOLUME + }) + } + + this.data = options.data + this.palette = options.palette + this.isDirty = false + this.blockLight = options.blockLight + this.skyLight = options.skyLight + this.solidBlockCount = options.solidBlockCount + this.maxBitsPerBlock = options.maxBitsPerBlock || GLOBAL_BITS_PER_BLOCK + } + + toJson () { + return JSON.stringify({ + data: this.data.toJson(), + palette: this.palette, + isDirty: this.isDirty, + blockLight: this.blockLight.toJson(), + skyLight: this.skyLight ? this.skyLight.toJson() : this.skyLight, + solidBlockCount: this.solidBlockCount + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + return new ChunkSection({ + data: BitArray.fromJson(parsed.data), + palette: parsed.palette, + blockLight: BitArray.fromJson(parsed.blockLight), + skyLight: parsed.skyLight ? BitArray.fromJson(parsed.skyLight) : parsed.skyLight, + solidBlockCount: parsed.solidBlockCount + }) + } + + getBlock (pos) { + // index in palette or block id + // depending on if the global palette or the section palette is used + let stateId = this.data.get(getBlockIndex(pos)) + + if (this.palette !== null) { + stateId = this.palette[stateId] + } + + return stateId + } + + setBlock (pos, stateId) { + const blockIndex = getBlockIndex(pos) + let palettedIndex + if (this.palette !== null) { + // if necessary, add the block to the palette + const indexInPalette = this.palette.indexOf(stateId) // binarySearch(this.palette, stateId, cmp) + if (indexInPalette >= 0) { + // block already in our palette + palettedIndex = indexInPalette + } else { + // get new block palette index + this.palette.push(stateId) + palettedIndex = this.palette.length - 1 + + // check if resize is necessary + const bitsPerValue = neededBits(palettedIndex) + + // if new block requires more bits than the current data array + if (bitsPerValue > this.data.getBitsPerValue()) { + // is value still enough for section palette + if (bitsPerValue <= constants.MAX_BITS_PER_BLOCK) { + this.data = this.data.resizeTo(bitsPerValue) + } else { + // switches to the global palette + const newData = new BitArray({ + bitsPerValue: this.maxBitsPerBlock, + capacity: constants.BLOCK_SECTION_VOLUME + }) + for (let i = 0; i < constants.BLOCK_SECTION_VOLUME; i++) { + const stateId = this.palette[this.data.get(i)] + newData.set(i, stateId) + } + + this.palette = null + palettedIndex = stateId + this.data = newData + } + } + } + } else { + // uses global palette + palettedIndex = stateId + } + + const oldBlock = this.getBlock(pos) + if (stateId === 0 && oldBlock !== 0) { + this.solidBlockCount -= 1 + } else if (stateId !== 0 && oldBlock === 0) { + this.solidBlockCount += 1 + } + + this.data.set(blockIndex, palettedIndex) + } + + getBlockLight (pos) { + return this.blockLight.get(getBlockIndex(pos)) + } + + getSkyLight (pos) { + return this.skyLight ? this.skyLight.get(getBlockIndex(pos)) : 0 + } + + setBlockLight (pos, light) { + return this.blockLight.set(getBlockIndex(pos), light) + } + + setSkyLight (pos, light) { + return this.skyLight ? this.skyLight.set(getBlockIndex(pos), light) : 0 + } + + isEmpty () { + return this.solidBlockCount === 0 + } + + // writes the complete section into a smart buffer object + write (smartBuffer) { + smartBuffer.writeUInt8(this.data.getBitsPerValue()) + + // write palette + if (this.palette !== null) { + varInt.write(smartBuffer, this.palette.length) + this.palette.forEach(paletteElement => { + varInt.write(smartBuffer, paletteElement) + }) + } + + // write block data + varInt.write(smartBuffer, this.data.length()) + this.data.writeBuffer(smartBuffer) + + // write block light data + this.blockLight.writeBuffer(smartBuffer) + + if (this.skyLight !== null) { + // write sky light data + this.skyLight.writeBuffer(smartBuffer) + } + } +} + +module.exports = ChunkSection diff --git a/bridge/lib/prismarine-chunk/src/pc/1.13/chunk.js b/bridge/lib/prismarine-chunk/src/pc/1.13/chunk.js new file mode 100644 index 0000000..2067ece --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.13/chunk.js @@ -0,0 +1,13 @@ +const constants = require('../common/constants') + +function loader (registry) { + const Block = require('prismarine-block')(registry) + + const Chunk = require('./ChunkColumn')(Block, registry) + // expose for test purposes + Chunk.h = constants.CHUNK_HEIGHT + Chunk.version = registry.version + return Chunk +} + +module.exports = loader diff --git a/bridge/lib/prismarine-chunk/src/pc/1.14/ChunkColumn.js b/bridge/lib/prismarine-chunk/src/pc/1.14/ChunkColumn.js new file mode 100644 index 0000000..60061ff --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.14/ChunkColumn.js @@ -0,0 +1,364 @@ +const SmartBuffer = require('smart-buffer').SmartBuffer +const BitArray = require('../common/BitArray') +const ChunkSection = require('../common/CommonChunkSection')(BitArray) +const CommonChunkColumn = require('../common/CommonChunkColumn') +const constants = require('../common/constants') +const varInt = require('../common/varInt') +const neededBits = require('../common/neededBits') + +// wrap with func to provide version specific Block +module.exports = (Block, mcData) => { + return class ChunkColumn extends CommonChunkColumn { + static get section () { return ChunkSection } + constructor () { + super(mcData) + this.sectionMask = 0 + this.sections = Array(constants.NUM_SECTIONS).fill(null) + this.biomes = Array( + constants.SECTION_WIDTH * constants.SECTION_WIDTH + ).fill(1) + this.skyLightMask = 0 + this.blockLightMask = 0 + this.skyLightSections = Array(constants.NUM_SECTIONS + 2).fill(null) + this.blockLightSections = Array(constants.NUM_SECTIONS + 2).fill(null) + this.maxBitsPerBlock = neededBits(Object.values(mcData.blocks).reduce((high, block) => Math.max(high, block.maxStateId), 0)) + } + + toJson () { + return JSON.stringify({ + biomes: this.biomes, + blockEntities: this.blockEntities, + sectionMask: this.sectionMask, + sections: this.sections.map(section => section === null ? null : section.toJson()), + skyLightMask: this.skyLightMask, + blockLightMask: this.blockLightMask, + skyLightSections: this.skyLightSections.map(section => section === null ? null : section.toJson()), + blockLightSections: this.blockLightSections.map(section => section === null ? null : section.toJson()) + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new ChunkColumn() + chunk.biomes = parsed.biomes + chunk.blockEntities = parsed.blockEntities + chunk.sectionMask = parsed.sectionMask + chunk.sections = parsed.sections.map(s => s === null ? null : ChunkSection.fromJson(s)) + chunk.skyLightMask = parsed.skyLightMask + chunk.blockLightMask = parsed.blockLightMask + chunk.skyLightSections = parsed.skyLightSections.map(s => s === null ? null : BitArray.fromJson(s)) + chunk.blockLightSections = parsed.blockLightSections.map(s => s === null ? null : BitArray.fromJson(s)) + return chunk + } + + initialize (func) { + const p = { x: 0, y: 0, z: 0 } + for (p.y = 0; p.y < constants.CHUNK_HEIGHT; p.y++) { + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + const block = func(p.x, p.y, p.z) + if (block === null) { + continue + } + this.setBlock(p, block) + } + } + } + } + + getBlock (pos) { + const section = this.sections[pos.y >> 4] + const biome = this.getBiome(pos) + if (!section) { + return Block.fromStateId(0, biome) + } + const stateId = section.getBlock(toSectionPos(pos)) + const block = Block.fromStateId(stateId, biome) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + block.entity = this.getBlockEntity(pos) + return block + } + + setBlock (pos, block) { + if (typeof block.stateId !== 'undefined') { + this.setBlockStateId(pos, block.stateId) + } + if (typeof block.biome !== 'undefined') { + this.setBiome(pos, block.biome.id) + } + if (typeof block.skyLight !== 'undefined') { + this.setSkyLight(pos, block.skyLight) + } + if (typeof block.light !== 'undefined') { + this.setBlockLight(pos, block.light) + } + if (block.entity) { + this.setBlockEntity(pos, block.entity) + } else { + this.removeBlockEntity(pos) + } + } + + getBlockType (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].id + } + + getBlockData (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].metadata + } + + getBlockStateId (pos) { + const section = this.sections[pos.y >> 4] + return section ? section.getBlock(toSectionPos(pos)) : 0 + } + + getBlockLight (pos) { + const section = this.blockLightSections[getLightSectionIndex(pos)] + return section ? section.get(getSectionBlockIndex(pos)) : 0 + } + + getSkyLight (pos) { + const section = this.skyLightSections[getLightSectionIndex(pos)] + return section ? section.get(getSectionBlockIndex(pos)) : 0 + } + + getBiome (pos) { + return this.biomes[getBiomeIndex(pos)] + } + + getBiomeColor (pos) { + // TODO + return { r: 0, g: 0, b: 0 } + } + + setBlockType (pos, id) { + this.setBlockStateId(pos, mcData.blocks[id].minStateId) + } + + setBlockData (pos, data) { + this.setBlockStateId(pos, mcData.blocksByStateId[this.getBlockStateId(pos)].minStateId + data) + } + + setBlockStateId (pos, stateId) { + const sectionIndex = pos.y >> 4 + if (sectionIndex < 0 || sectionIndex >= 16) return + + let section = this.sections[sectionIndex] + if (!section) { + // if it's air + if (stateId === 0) { + return + } + section = new ChunkSection({ + maxBitsPerBlock: this.maxBitsPerBlock + }) + this.sectionMask |= 1 << sectionIndex + this.sections[sectionIndex] = section + } + + section.setBlock(toSectionPos(pos), stateId) + } + + setBlockLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos) + let section = this.blockLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + this.blockLightMask |= 1 << sectionIndex + this.blockLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos), light) + } + + setSkyLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos) + let section = this.skyLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + this.skyLightMask |= 1 << sectionIndex + this.skyLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos), light) + } + + setBiome (pos, biome) { + this.biomes[getBiomeIndex(pos)] = biome + } + + getMask () { + return this.sectionMask + } + + dump () { + const smartBuffer = new SmartBuffer() + this.sections.forEach((section, i) => { + if (section !== null && !section.isEmpty()) { + section.write(smartBuffer) + } + }) + + // write biome data + this.biomes.forEach(biome => { + smartBuffer.writeInt32BE(biome) + }) + + return smartBuffer.toBuffer() + } + + load (data, bitMap = 0xffff, skyLightSent = true, fullChunk = true) { + // make smartbuffer from node buffer + // so that we doesn't need to maintain a cursor + const reader = SmartBuffer.fromBuffer(data) + + this.sectionMask |= bitMap + for (let y = 0; y < constants.NUM_SECTIONS; ++y) { + // does `data` contain this chunk? + if (!((bitMap >> y) & 1)) { + // we can skip write a section if it isn't requested + continue + } + + // keep temporary palette + let palette + + const solidBlockCount = reader.readInt16BE() + + // get number of bits a palette item use + const bitsPerBlock = reader.readUInt8() + + // check if the section uses a section palette + if (bitsPerBlock <= constants.MAX_BITS_PER_BLOCK) { + palette = [] + // get number of palette items + const numPaletteItems = varInt.read(reader) + + // save each palette item + for (let i = 0; i < numPaletteItems; ++i) { + palette.push(varInt.read(reader)) + } + } else { + // global palette is used + palette = null + } + + // number of items in data array + const dataArray = new BitArray({ + bitsPerValue: bitsPerBlock > constants.MAX_BITS_PER_BLOCK ? this.maxBitsPerBlock : bitsPerBlock, + capacity: 4096 + }).readBuffer(reader, varInt.read(reader) * 2) + + const section = new ChunkSection({ + data: dataArray, + palette, + solidBlockCount, + maxBitsPerBlock: this.maxBitsPerBlock + }) + this.sections[y] = section + } + + // read biomes + if (fullChunk) { + const p = { x: 0, y: 0, z: 0 } + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + this.setBiome(p, reader.readInt32BE()) + } + } + } + } + + loadLight (data, skyLightMask, blockLightMask, emptySkyLightMask = 0, emptyBlockLightMask = 0) { + const reader = SmartBuffer.fromBuffer(data) + + // Read sky light + this.skyLightMask |= skyLightMask + for (let y = 0; y < constants.NUM_SECTIONS + 2; y++) { + if (!((skyLightMask >> y) & 1)) { + continue + } + varInt.read(reader) // always 2048 + this.skyLightSections[y] = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + } + + // Read block light + this.blockLightMask |= blockLightMask + for (let y = 0; y < constants.NUM_SECTIONS + 2; y++) { + if (!((blockLightMask >> y) & 1)) { + continue + } + varInt.read(reader) // always 2048 + this.blockLightSections[y] = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + } + } + + // These methods do nothing, and are present only for API compatibility + dumpBiomes () { + + } + + loadBiomes () { + + } + + dumpLight () { + const smartBuffer = new SmartBuffer() + + this.skyLightSections.forEach((section, i) => { + if (section !== null) { + varInt.write(smartBuffer, 2048) + section.writeBuffer(smartBuffer) + } + }) + + this.blockLightSections.forEach((section, i) => { + if (section !== null) { + varInt.write(smartBuffer, 2048) + section.writeBuffer(smartBuffer) + } + }) + + return smartBuffer.toBuffer() + } + } +} + +function getLightSectionIndex (pos) { + return Math.floor(pos.y / 16) + 1 +} + +function getBiomeIndex (pos) { + return (pos.z * 16) | pos.x +} + +function toSectionPos (pos) { + return { x: pos.x, y: pos.y & 15, z: pos.z } +} + +function getSectionBlockIndex (pos) { + return ((pos.y & 15) << 8) | (pos.z << 4) | pos.x +} diff --git a/bridge/lib/prismarine-chunk/src/pc/1.14/chunk.js b/bridge/lib/prismarine-chunk/src/pc/1.14/chunk.js new file mode 100644 index 0000000..2067ece --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.14/chunk.js @@ -0,0 +1,13 @@ +const constants = require('../common/constants') + +function loader (registry) { + const Block = require('prismarine-block')(registry) + + const Chunk = require('./ChunkColumn')(Block, registry) + // expose for test purposes + Chunk.h = constants.CHUNK_HEIGHT + Chunk.version = registry.version + return Chunk +} + +module.exports = loader diff --git a/bridge/lib/prismarine-chunk/src/pc/1.15/ChunkColumn.js b/bridge/lib/prismarine-chunk/src/pc/1.15/ChunkColumn.js new file mode 100644 index 0000000..066b520 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.15/ChunkColumn.js @@ -0,0 +1,342 @@ +const SmartBuffer = require('smart-buffer').SmartBuffer +const BitArray = require('../common/BitArray') +const ChunkSection = require('../common/CommonChunkSection')(BitArray) +const CommonChunkColumn = require('../common/CommonChunkColumn') +const constants = require('../common/constants') +const varInt = require('../common/varInt') +const neededBits = require('../common/neededBits') + +// wrap with func to provide version specific Block +module.exports = (Block, mcData) => { + return class ChunkColumn extends CommonChunkColumn { + static get section () { return ChunkSection } + constructor () { + super(mcData) + this.sectionMask = 0 + this.sections = Array(constants.NUM_SECTIONS).fill(null) + this.biomes = Array(4 * 4 * 64).fill(127) + this.skyLightMask = 0 + this.blockLightMask = 0 + this.skyLightSections = Array(constants.NUM_SECTIONS + 2).fill(null) + this.blockLightSections = Array(constants.NUM_SECTIONS + 2).fill(null) + this.maxBitsPerBlock = neededBits(Object.values(mcData.blocks).reduce((high, block) => Math.max(high, block.maxStateId), 0)) + } + + toJson () { + return JSON.stringify({ + biomes: this.biomes, + blockEntities: this.blockEntities, + sectionMask: this.sectionMask, + sections: this.sections.map(section => section === null ? null : section.toJson()), + skyLightMask: this.skyLightMask, + blockLightMask: this.blockLightMask, + skyLightSections: this.skyLightSections.map(section => section === null ? null : section.toJson()), + blockLightSections: this.blockLightSections.map(section => section === null ? null : section.toJson()) + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new ChunkColumn() + chunk.biomes = parsed.biomes + chunk.blockEntities = parsed.blockEntities + chunk.sectionMask = parsed.sectionMask + chunk.sections = parsed.sections.map(s => s === null ? null : ChunkSection.fromJson(s)) + chunk.skyLightMask = parsed.skyLightMask + chunk.blockLightMask = parsed.blockLightMask + chunk.skyLightSections = parsed.skyLightSections.map(s => s === null ? null : BitArray.fromJson(s)) + chunk.blockLightSections = parsed.blockLightSections.map(s => s === null ? null : BitArray.fromJson(s)) + return chunk + } + + initialize (func) { + const p = { x: 0, y: 0, z: 0 } + for (p.y = 0; p.y < constants.CHUNK_HEIGHT; p.y++) { + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + const block = func(p.x, p.y, p.z) + if (block === null) { + continue + } + this.setBlock(p, block) + } + } + } + } + + getBlock (pos) { + const section = this.sections[pos.y >> 4] + const biome = this.getBiome(pos) + if (!section) { + return Block.fromStateId(0, biome) + } + const stateId = section.getBlock(toSectionPos(pos)) + const block = Block.fromStateId(stateId, biome) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + block.entity = this.getBlockEntity(pos) + return block + } + + setBlock (pos, block) { + if (typeof block.stateId !== 'undefined') { + this.setBlockStateId(pos, block.stateId) + } + if (typeof block.biome !== 'undefined') { + this.setBiome(pos, block.biome.id) + } + if (typeof block.skyLight !== 'undefined') { + this.setSkyLight(pos, block.skyLight) + } + if (typeof block.light !== 'undefined') { + this.setBlockLight(pos, block.light) + } + if (block.entity) { + this.setBlockEntity(pos, block.entity) + } else { + this.removeBlockEntity(pos) + } + } + + getBlockType (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].id + } + + getBlockData (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].metadata + } + + getBlockStateId (pos) { + const section = this.sections[pos.y >> 4] + return section ? section.getBlock(toSectionPos(pos)) : 0 + } + + getBlockLight (pos) { + const section = this.blockLightSections[getLightSectionIndex(pos)] + return section ? section.get(getSectionBlockIndex(pos)) : 0 + } + + getSkyLight (pos) { + const section = this.skyLightSections[getLightSectionIndex(pos)] + return section ? section.get(getSectionBlockIndex(pos)) : 0 + } + + getBiome (pos) { + if (pos.y < 0 || pos.y >= 256) return 0 + return this.biomes[getBiomeIndex(pos)] + } + + setBlockType (pos, id) { + this.setBlockStateId(pos, mcData.blocks[id].minStateId) + } + + setBlockData (pos, data) { + this.setBlockStateId(pos, mcData.blocksByStateId[this.getBlockStateId(pos)].minStateId + data) + } + + setBlockStateId (pos, stateId) { + const sectionIndex = pos.y >> 4 + if (sectionIndex < 0 || sectionIndex >= 16) return + + let section = this.sections[sectionIndex] + if (!section) { + // if it's air + if (stateId === 0) { + return + } + section = new ChunkSection({ + maxBitsPerBlock: this.maxBitsPerBlock + }) + this.sectionMask |= 1 << sectionIndex + this.sections[sectionIndex] = section + } + + section.setBlock(toSectionPos(pos), stateId) + } + + setBlockLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos) + let section = this.blockLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + this.blockLightMask |= 1 << sectionIndex + this.blockLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos), light) + } + + setSkyLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos) + let section = this.skyLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + this.skyLightMask |= 1 << sectionIndex + this.skyLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos), light) + } + + setBiome (pos, biome) { + if (pos.y < 0 || pos.y >= 256) return + this.biomes[getBiomeIndex(pos)] = biome + } + + getMask () { + return this.sectionMask + } + + dump () { + const smartBuffer = new SmartBuffer() + this.sections.forEach((section, i) => { + if (section !== null && !section.isEmpty()) { + section.write(smartBuffer) + } + }) + return smartBuffer.toBuffer() + } + + loadBiomes (biomes) { + this.biomes = biomes + } + + dumpBiomes (biomes) { + return this.biomes + } + + load (data, bitMap = 0xffff) { + // make smartbuffer from node buffer + // so that we doesn't need to maintain a cursor + const reader = SmartBuffer.fromBuffer(data) + + this.sectionMask |= bitMap + for (let y = 0; y < constants.NUM_SECTIONS; ++y) { + // does `data` contain this chunk? + if (!((bitMap >> y) & 1)) { + // we can skip write a section if it isn't requested + continue + } + + // keep temporary palette + let palette + + const solidBlockCount = reader.readInt16BE() + + // get number of bits a palette item use + const bitsPerBlock = reader.readUInt8() + + // check if the section uses a section palette + if (bitsPerBlock <= constants.MAX_BITS_PER_BLOCK) { + palette = [] + // get number of palette items + const numPaletteItems = varInt.read(reader) + + // save each palette item + for (let i = 0; i < numPaletteItems; ++i) { + palette.push(varInt.read(reader)) + } + } else { + // global palette is used + palette = null + } + + // number of items in data array + const dataArray = new BitArray({ + bitsPerValue: bitsPerBlock > constants.MAX_BITS_PER_BLOCK ? this.maxBitsPerBlock : bitsPerBlock, + capacity: 4096 + }).readBuffer(reader, varInt.read(reader) * 2) + + const section = new ChunkSection({ + data: dataArray, + palette, + solidBlockCount, + maxBitsPerBlock: this.maxBitsPerBlock + }) + this.sections[y] = section + } + } + + loadLight (data, skyLightMask, blockLightMask, emptySkyLightMask = 0, emptyBlockLightMask = 0) { + const reader = SmartBuffer.fromBuffer(data) + + // Read sky light + this.skyLightMask |= skyLightMask + for (let y = 0; y < constants.NUM_SECTIONS + 2; y++) { + if (!((skyLightMask >> y) & 1)) { + continue + } + varInt.read(reader) // always 2048 + this.skyLightSections[y] = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + } + + // Read block light + this.blockLightMask |= blockLightMask + for (let y = 0; y < constants.NUM_SECTIONS + 2; y++) { + if (!((blockLightMask >> y) & 1)) { + continue + } + varInt.read(reader) // always 2048 + this.blockLightSections[y] = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + } + } + + dumpLight () { + const smartBuffer = new SmartBuffer() + + this.skyLightSections.forEach((section, i) => { + if (section !== null) { + varInt.write(smartBuffer, 2048) + section.writeBuffer(smartBuffer) + } + }) + + this.blockLightSections.forEach((section, i) => { + if (section !== null) { + varInt.write(smartBuffer, 2048) + section.writeBuffer(smartBuffer) + } + }) + + return smartBuffer.toBuffer() + } + } +} + +function getLightSectionIndex (pos) { + return Math.floor(pos.y / 16) + 1 +} + +function getBiomeIndex (pos) { + return ((pos.y >> 2) & 63) << 4 | ((pos.z >> 2) & 3) << 2 | ((pos.x >> 2) & 3) +} + +function toSectionPos (pos) { + return { x: pos.x, y: pos.y & 15, z: pos.z } +} + +function getSectionBlockIndex (pos) { + return ((pos.y & 15) << 8) | (pos.z << 4) | pos.x +} diff --git a/bridge/lib/prismarine-chunk/src/pc/1.15/chunk.js b/bridge/lib/prismarine-chunk/src/pc/1.15/chunk.js new file mode 100644 index 0000000..2067ece --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.15/chunk.js @@ -0,0 +1,13 @@ +const constants = require('../common/constants') + +function loader (registry) { + const Block = require('prismarine-block')(registry) + + const Chunk = require('./ChunkColumn')(Block, registry) + // expose for test purposes + Chunk.h = constants.CHUNK_HEIGHT + Chunk.version = registry.version + return Chunk +} + +module.exports = loader diff --git a/bridge/lib/prismarine-chunk/src/pc/1.16/ChunkColumn.js b/bridge/lib/prismarine-chunk/src/pc/1.16/ChunkColumn.js new file mode 100644 index 0000000..0bf0f88 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.16/ChunkColumn.js @@ -0,0 +1,342 @@ +const SmartBuffer = require('smart-buffer').SmartBuffer +const BitArray = require('../common/BitArrayNoSpan') +const ChunkSection = require('../common/CommonChunkSection')(BitArray) +const CommonChunkColumn = require('../common/CommonChunkColumn') +const constants = require('../common/constants') +const varInt = require('../common/varInt') +const neededBits = require('../common/neededBits') + +// wrap with func to provide version specific Block +module.exports = (Block, mcData) => { + return class ChunkColumn extends CommonChunkColumn { + static get section () { return ChunkSection } + constructor () { + super(mcData) + this.sectionMask = 0 + this.sections = Array(constants.NUM_SECTIONS).fill(null) + this.biomes = Array(4 * 4 * 64).fill(127) + this.skyLightMask = 0 + this.blockLightMask = 0 + this.skyLightSections = Array(constants.NUM_SECTIONS + 2).fill(null) + this.blockLightSections = Array(constants.NUM_SECTIONS + 2).fill(null) + this.maxBitsPerBlock = neededBits(Object.values(mcData.blocks).reduce((high, block) => Math.max(high, block.maxStateId), 0)) + } + + toJson () { + return JSON.stringify({ + biomes: this.biomes, + blockEntities: this.blockEntities, + sectionMask: this.sectionMask, + sections: this.sections.map(section => section === null ? null : section.toJson()), + skyLightMask: this.skyLightMask, + blockLightMask: this.blockLightMask, + skyLightSections: this.skyLightSections.map(section => section === null ? null : section.toJson()), + blockLightSections: this.blockLightSections.map(section => section === null ? null : section.toJson()) + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new ChunkColumn() + chunk.biomes = parsed.biomes + chunk.blockEntities = parsed.blockEntities + chunk.sectionMask = parsed.sectionMask + chunk.sections = parsed.sections.map(s => s === null ? null : ChunkSection.fromJson(s)) + chunk.skyLightMask = parsed.skyLightMask + chunk.blockLightMask = parsed.blockLightMask + chunk.skyLightSections = parsed.skyLightSections.map(s => s === null ? null : BitArray.fromJson(s)) + chunk.blockLightSections = parsed.blockLightSections.map(s => s === null ? null : BitArray.fromJson(s)) + return chunk + } + + initialize (func) { + const p = { x: 0, y: 0, z: 0 } + for (p.y = 0; p.y < constants.CHUNK_HEIGHT; p.y++) { + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + const block = func(p.x, p.y, p.z) + if (block === null) { + continue + } + this.setBlock(p, block) + } + } + } + } + + getBlock (pos) { + const section = this.sections[pos.y >> 4] + const biome = this.getBiome(pos) + if (!section) { + return Block.fromStateId(0, biome) + } + const stateId = section.getBlock(toSectionPos(pos)) + const block = Block.fromStateId(stateId, biome) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + block.entity = this.getBlockEntity(pos) + return block + } + + setBlock (pos, block) { + if (typeof block.stateId !== 'undefined') { + this.setBlockStateId(pos, block.stateId) + } + if (typeof block.biome !== 'undefined') { + this.setBiome(pos, block.biome.id) + } + if (typeof block.skyLight !== 'undefined') { + this.setSkyLight(pos, block.skyLight) + } + if (typeof block.light !== 'undefined') { + this.setBlockLight(pos, block.light) + } + if (block.entity) { + this.setBlockEntity(pos, block.entity) + } else { + this.removeBlockEntity(pos) + } + } + + getBlockType (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].id + } + + getBlockData (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].metadata + } + + getBlockStateId (pos) { + const section = this.sections[pos.y >> 4] + return section ? section.getBlock(toSectionPos(pos)) : 0 + } + + getBlockLight (pos) { + const section = this.blockLightSections[getLightSectionIndex(pos)] + return section ? section.get(getSectionBlockIndex(pos)) : 0 + } + + getSkyLight (pos) { + const section = this.skyLightSections[getLightSectionIndex(pos)] + return section ? section.get(getSectionBlockIndex(pos)) : 0 + } + + getBiome (pos) { + if (pos.y < 0 || pos.y >= 256) return 0 + return this.biomes[getBiomeIndex(pos)] + } + + setBlockType (pos, id) { + this.setBlockStateId(pos, mcData.blocks[id].minStateId) + } + + setBlockData (pos, data) { + this.setBlockStateId(pos, mcData.blocksByStateId[this.getBlockStateId(pos)].minStateId + data) + } + + setBlockStateId (pos, stateId) { + const sectionIndex = pos.y >> 4 + if (sectionIndex < 0 || sectionIndex >= 16) return + + let section = this.sections[sectionIndex] + if (!section) { + // if it's air + if (stateId === 0) { + return + } + section = new ChunkSection({ + maxBitsPerBlock: this.maxBitsPerBlock + }) + this.sectionMask |= 1 << sectionIndex + this.sections[sectionIndex] = section + } + + section.setBlock(toSectionPos(pos), stateId) + } + + setBlockLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos) + let section = this.blockLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + this.blockLightMask |= 1 << sectionIndex + this.blockLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos), light) + } + + setSkyLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos) + let section = this.skyLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + this.skyLightMask |= 1 << sectionIndex + this.skyLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos), light) + } + + setBiome (pos, biome) { + if (pos.y < 0 || pos.y >= 256) return + this.biomes[getBiomeIndex(pos)] = biome + } + + getMask () { + return this.sectionMask + } + + dump () { + const smartBuffer = new SmartBuffer() + this.sections.forEach((section, i) => { + if (section !== null && !section.isEmpty()) { + section.write(smartBuffer) + } + }) + return smartBuffer.toBuffer() + } + + loadBiomes (biomes) { + this.biomes = biomes + } + + dumpBiomes (biomes) { + return this.biomes + } + + load (data, bitMap = 0xffff) { + // make smartbuffer from node buffer + // so that we doesn't need to maintain a cursor + const reader = SmartBuffer.fromBuffer(data) + + this.sectionMask |= bitMap + for (let y = 0; y < constants.NUM_SECTIONS; ++y) { + // does `data` contain this chunk? + if (!((bitMap >> y) & 1)) { + // we can skip write a section if it isn't requested + continue + } + + // keep temporary palette + let palette + + const solidBlockCount = reader.readInt16BE() + + // get number of bits a palette item use + const bitsPerBlock = reader.readUInt8() + + // check if the section uses a section palette + if (bitsPerBlock <= constants.MAX_BITS_PER_BLOCK) { + palette = [] + // get number of palette items + const numPaletteItems = varInt.read(reader) + + // save each palette item + for (let i = 0; i < numPaletteItems; ++i) { + palette.push(varInt.read(reader)) + } + } else { + // global palette is used + palette = null + } + + // number of items in data array + const dataArray = new BitArray({ + bitsPerValue: bitsPerBlock > constants.MAX_BITS_PER_BLOCK ? this.maxBitsPerBlock : bitsPerBlock, + capacity: 4096 + }).readBuffer(reader, varInt.read(reader) * 2) + + const section = new ChunkSection({ + data: dataArray, + palette, + solidBlockCount, + maxBitsPerBlock: this.maxBitsPerBlock + }) + this.sections[y] = section + } + } + + loadLight (data, skyLightMask, blockLightMask, emptySkyLightMask = 0, emptyBlockLightMask = 0) { + const reader = SmartBuffer.fromBuffer(data) + + // Read sky light + this.skyLightMask |= skyLightMask + for (let y = 0; y < constants.NUM_SECTIONS + 2; y++) { + if (!((skyLightMask >> y) & 1)) { + continue + } + varInt.read(reader) // always 2048 + this.skyLightSections[y] = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + } + + // Read block light + this.blockLightMask |= blockLightMask + for (let y = 0; y < constants.NUM_SECTIONS + 2; y++) { + if (!((blockLightMask >> y) & 1)) { + continue + } + varInt.read(reader) // always 2048 + this.blockLightSections[y] = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + } + } + + dumpLight () { + const smartBuffer = new SmartBuffer() + + this.skyLightSections.forEach((section, i) => { + if (section !== null) { + varInt.write(smartBuffer, 2048) + section.writeBuffer(smartBuffer) + } + }) + + this.blockLightSections.forEach((section, i) => { + if (section !== null) { + varInt.write(smartBuffer, 2048) + section.writeBuffer(smartBuffer) + } + }) + + return smartBuffer.toBuffer() + } + } +} + +function getLightSectionIndex (pos) { + return Math.floor(pos.y / 16) + 1 +} + +function getBiomeIndex (pos) { + return ((pos.y >> 2) & 63) << 4 | ((pos.z >> 2) & 3) << 2 | ((pos.x >> 2) & 3) +} + +function toSectionPos (pos) { + return { x: pos.x, y: pos.y & 15, z: pos.z } +} + +function getSectionBlockIndex (pos) { + return ((pos.y & 15) << 8) | (pos.z << 4) | pos.x +} diff --git a/bridge/lib/prismarine-chunk/src/pc/1.16/chunk.js b/bridge/lib/prismarine-chunk/src/pc/1.16/chunk.js new file mode 100644 index 0000000..2067ece --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.16/chunk.js @@ -0,0 +1,13 @@ +const constants = require('../common/constants') + +function loader (registry) { + const Block = require('prismarine-block')(registry) + + const Chunk = require('./ChunkColumn')(Block, registry) + // expose for test purposes + Chunk.h = constants.CHUNK_HEIGHT + Chunk.version = registry.version + return Chunk +} + +module.exports = loader diff --git a/bridge/lib/prismarine-chunk/src/pc/1.17/ChunkColumn.js b/bridge/lib/prismarine-chunk/src/pc/1.17/ChunkColumn.js new file mode 100644 index 0000000..8990c67 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.17/ChunkColumn.js @@ -0,0 +1,393 @@ +const SmartBuffer = require('smart-buffer').SmartBuffer +const BitArray = require('../common/BitArrayNoSpan') +const ChunkSection = require('../common/CommonChunkSection')(BitArray) +const CommonChunkColumn = require('../common/CommonChunkColumn') +const constants = require('../common/constants') +const varInt = require('../common/varInt') +const neededBits = require('../common/neededBits') + +// wrap with func to provide version specific Block +module.exports = (Block, mcData) => { + return class ChunkColumn extends CommonChunkColumn { + static get section () { return ChunkSection } + constructor (options) { + super(mcData) + this.minY = options?.minY ?? 0 + this.worldHeight = options?.worldHeight ?? constants.CHUNK_HEIGHT + this.numSections = this.worldHeight >> 4 + this.maxBitsPerBlock = neededBits(Object.values(mcData.blocks).reduce((high, block) => Math.max(high, block.maxStateId), 0)) + + this.sectionMask = new BitArray({ + bitsPerValue: 1, + capacity: this.numSections + }) + this.sections = Array(this.numSections).fill(null) + this.biomes = Array(4 * 4 * (this.worldHeight >> 2)).fill(0) + + this.skyLightMask = new BitArray({ + bitsPerValue: 1, + capacity: this.numSections + 2 + }) + this.emptySkyLightMask = new BitArray({ + bitsPerValue: 1, + capacity: this.numSections + 2 + }) + + this.skyLightSections = Array(this.numSections + 2).fill(null) + + this.blockLightMask = new BitArray({ + bitsPerValue: 1, + capacity: this.numSections + 2 + }) + this.emptyBlockLightMask = new BitArray({ + bitsPerValue: 1, + capacity: this.numSections + 2 + }) + + this.blockLightSections = Array(this.numSections + 2).fill(null) + } + + // Json serialization/deserialization methods + toJson () { + return JSON.stringify({ + biomes: this.biomes, + blockEntities: this.blockEntities, + sectionMask: this.sectionMask.toLongArray(), + sections: this.sections.map(section => section === null ? null : section.toJson()), + + skyLightMask: this.skyLightMask.toLongArray(), + blockLightMask: this.blockLightMask.toLongArray(), + + skyLightSections: this.skyLightSections.map(section => section === null ? null : section.toJson()), + blockLightSections: this.blockLightSections.map(section => section === null ? null : section.toJson()), + + emptyBlockLightMask: this.emptyBlockLightMask.toLongArray(), + emptySkyLightMask: this.emptySkyLightMask.toLongArray() + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new ChunkColumn() + chunk.biomes = parsed.biomes + chunk.blockEntities = parsed.blockEntities + chunk.sectionMask = BitArray.fromLongArray(parsed.sectionMask, 1) + chunk.sections = parsed.sections.map(s => s === null ? null : ChunkSection.fromJson(s)) + + chunk.skyLightMask = BitArray.fromLongArray(parsed.skyLightMask, 1) + chunk.blockLightMask = BitArray.fromLongArray(parsed.blockLightMask, 1) + + chunk.skyLightSections = parsed.skyLightSections.map(s => s === null ? null : BitArray.fromJson(s)) + chunk.blockLightSections = parsed.blockLightSections.map(s => s === null ? null : BitArray.fromJson(s)) + + chunk.emptySkyLightMask = BitArray.fromLongArray(parsed.emptyBlockLightMask, 1) + chunk.emptyBlockLightMask = BitArray.fromLongArray(parsed.emptySkyLightMask, 1) + + return chunk + } + + initialize (func) { + const p = { x: 0, y: 0, z: 0 } + for (p.y = 0; p.y < this.worldHeight; p.y++) { + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + const block = func(p.x, p.y, p.z) + if (block === null) { + continue + } + this.setBlock(p, block) + } + } + } + } + + getBlock (pos) { + const section = this.sections[(pos.y - this.minY) >> 4] + const biome = this.getBiome(pos) + if (!section) { + return Block.fromStateId(0, biome) + } + const stateId = section.getBlock(toSectionPos(pos, this.minY)) + const block = Block.fromStateId(stateId, biome) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + block.entity = this.getBlockEntity(pos) + return block + } + + setBlock (pos, block) { + if (typeof block.stateId !== 'undefined') { + this.setBlockStateId(pos, block.stateId) + } + if (typeof block.biome !== 'undefined') { + this.setBiome(pos, block.biome.id) + } + if (typeof block.skyLight !== 'undefined') { + this.setSkyLight(pos, block.skyLight) + } + if (typeof block.light !== 'undefined') { + this.setBlockLight(pos, block.light) + } + if (block.entity) { + this.setBlockEntity(pos, block.entity) + } else { + this.removeBlockEntity(pos) + } + } + + getBlockType (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].id + } + + getBlockData (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].metadata + } + + getBlockStateId (pos) { + const section = this.sections[(pos.y - this.minY) >> 4] + return section ? section.getBlock(toSectionPos(pos, this.minY)) : 0 + } + + getBlockLight (pos) { + const section = this.blockLightSections[getLightSectionIndex(pos, this.minY)] + return section ? section.get(getSectionBlockIndex(pos, this.minY)) : 0 + } + + getSkyLight (pos) { + const section = this.skyLightSections[getLightSectionIndex(pos, this.minY)] + return section ? section.get(getSectionBlockIndex(pos, this.minY)) : 0 + } + + getBiome (pos) { + if (pos.y < this.minY || pos.y >= (this.minY + this.worldHeight)) return 0 + return this.biomes[getBiomeIndex(pos, this.minY)] + } + + setBlockType (pos, id) { + this.setBlockStateId(pos, mcData.blocks[id].minStateId) + } + + setBlockData (pos, data) { + this.setBlockStateId(pos, mcData.blocksByStateId[this.getBlockStateId(pos)].minStateId + data) + } + + setBlockStateId (pos, stateId) { + const sectionIndex = (pos.y - this.minY) >> 4 + if (sectionIndex < 0 || sectionIndex >= this.numSections) return + + let section = this.sections[sectionIndex] + if (!section) { + // if it's air + if (stateId === 0) { + return + } + section = new ChunkSection({ maxBitsPerBlock: this.maxBitsPerBlock }) + if (sectionIndex > this.sectionMask.capacity) { + this.sectionMask = this.sectionMask.resize(sectionIndex) + } + this.sectionMask.set(sectionIndex, 1) + this.sections[sectionIndex] = section + } + + section.setBlock(toSectionPos(pos, this.minY), stateId) + } + + setBlockLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos, this.minY) + let section = this.blockLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + if (sectionIndex > this.blockLightMask.capacity) { + this.blockLightMask = this.blockLightMask.resize(sectionIndex) + } + this.blockLightMask.set(sectionIndex, 1) + this.blockLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos, this.minY), light) + } + + setSkyLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos, this.minY) + let section = this.skyLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + this.skyLightMask.set(sectionIndex, 1) + this.skyLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos, this.minY), light) + } + + setBiome (pos, biome) { + if (pos.y < this.minY || pos.y >= (this.minY + this.worldHeight)) return + this.biomes[getBiomeIndex(pos, this.minY)] = biome + } + + getMask () { + return this.sectionMask.toLongArray() + } + + dump () { + const smartBuffer = new SmartBuffer() + this.sections.forEach((section) => { + if (section !== null && !section.isEmpty()) { + section.write(smartBuffer) + } + }) + return smartBuffer.toBuffer() + } + + loadBiomes (biomes) { + this.biomes = biomes + } + + dumpBiomes (biomes) { + return this.biomes + } + + load (data, bitMap = [[0, 0xffff]]) { + const reader = SmartBuffer.fromBuffer(data) + bitMap = BitArray.fromLongArray(bitMap, 1) + + this.sectionMask = BitArray.or(this.sectionMask, bitMap) + + for (let y = 0; y < this.numSections; ++y) { + // skip sections not present in the data + if (!bitMap.get(y)) { + continue + } + + // keep temporary palette + let palette + + const solidBlockCount = reader.readInt16BE() + + // get number of bits a palette item use + const bitsPerBlock = reader.readUInt8() + + // check if the section uses a section palette + if (bitsPerBlock <= constants.MAX_BITS_PER_BLOCK) { + palette = [] + // get number of palette items + const numPaletteItems = varInt.read(reader) + + // save each palette item + for (let i = 0; i < numPaletteItems; ++i) { + palette.push(varInt.read(reader)) + } + } else { + // global palette is used + palette = null + } + + // number of items in data array + const dataArray = new BitArray({ + bitsPerValue: bitsPerBlock > constants.MAX_BITS_PER_BLOCK ? this.maxBitsPerBlock : bitsPerBlock, + capacity: 4096 + }).readBuffer(reader, varInt.read(reader) * 2) + + this.sections[y] = new ChunkSection({ + data: dataArray, + palette, + solidBlockCount, + maxBitsPerBlock: this.maxBitsPerBlock + }) + } + } + + loadParsedLight (skyLight, blockLight, skyLightMask, blockLightMask, emptySkyLightMask, emptyBlockLightMask) { + function readSection (sections, data, lightMask, pLightMask, emptyMask, pEmptyMask) { + let currentSectionIndex = 0 + const incomingLightMask = BitArray.fromLongArray(pLightMask, 1) + const incomingEmptyMask = BitArray.fromLongArray(pEmptyMask, 1) + + for (let y = 0; y < sections.length; y++) { + const isEmpty = incomingEmptyMask.get(y) + if (!incomingLightMask.get(y) && !isEmpty) { + continue + } + + emptyMask.set(y, isEmpty) + lightMask.set(y, 1 - isEmpty) + + const bitArray = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + sections[y] = bitArray + + if (!isEmpty) { + const sectionReader = Buffer.from(data[currentSectionIndex++]) + bitArray.readBuffer(SmartBuffer.fromBuffer(sectionReader)) + } + } + } + + readSection(this.skyLightSections, skyLight, this.skyLightMask, skyLightMask, this.emptySkyLightMask, emptySkyLightMask) + readSection(this.blockLightSections, blockLight, this.blockLightMask, blockLightMask, this.emptyBlockLightMask, emptyBlockLightMask) + } + + dumpLight () { + const skyLight = [] + const blockLight = [] + + this.skyLightSections.forEach((section, index) => { + if (section !== null && this.skyLightMask.get(index)) { + const smartBuffer = new SmartBuffer() + section.writeBuffer(smartBuffer) + skyLight.push(Uint8Array.from(smartBuffer.toBuffer())) + } + }) + + this.blockLightSections.forEach((section, index) => { + if (section !== null && this.blockLightMask.get(index)) { + const smartBuffer = new SmartBuffer() + section.writeBuffer(smartBuffer) + blockLight.push(Uint8Array.from(smartBuffer.toBuffer())) + } + }) + + return { + skyLight, + blockLight, + skyLightMask: this.skyLightMask.toLongArray(), + blockLightMask: this.blockLightMask.toLongArray(), + emptySkyLightMask: this.emptySkyLightMask.toLongArray(), + emptyBlockLightMask: this.emptyBlockLightMask.toLongArray() + } + } + } +} + +function getLightSectionIndex (pos, minY) { + return Math.floor((pos.y - minY) / 16) + 1 +} + +function getBiomeIndex (pos, minY) { + return (((pos.y - minY) >> 2) & 63) << 4 | ((pos.z >> 2) & 3) << 2 | ((pos.x >> 2) & 3) +} + +function toSectionPos (pos, minY) { + return { x: pos.x, y: (pos.y - minY) & 15, z: pos.z } +} + +function getSectionBlockIndex (pos, minY) { + return (((pos.y - minY) & 15) << 8) | (pos.z << 4) | pos.x +} diff --git a/bridge/lib/prismarine-chunk/src/pc/1.17/chunk.js b/bridge/lib/prismarine-chunk/src/pc/1.17/chunk.js new file mode 100644 index 0000000..d7dd67c --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.17/chunk.js @@ -0,0 +1,7 @@ +function loader (registry) { + const Block = require('prismarine-block')(registry) + + return require('./ChunkColumn')(Block, registry) +} + +module.exports = loader diff --git a/bridge/lib/prismarine-chunk/src/pc/1.18/ChunkColumn.js b/bridge/lib/prismarine-chunk/src/pc/1.18/ChunkColumn.js new file mode 100644 index 0000000..33619f3 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.18/ChunkColumn.js @@ -0,0 +1,384 @@ +const SmartBuffer = require('smart-buffer').SmartBuffer +const BitArray = require('../common/BitArrayNoSpan') +const ChunkSection = require('../common/PaletteChunkSection') +const BiomeSection = require('../common/PaletteBiome') +const CommonChunkColumn = require('../common/CommonChunkColumn') +const constants = require('../common/constants') +const neededBits = require('../common/neededBits') + +const CAVES_UPDATE_MIN_Y = -64 +const CAVES_UPDATE_WORLD_HEIGHT = 384 + +// wrap with func to provide version specific Block +module.exports = (Block, mcData) => { + // 1.21.5+ writes no size prefix before chunk containers, it's computed dynamically to save 1 byte + const noSizePrefix = mcData.version['>=']('1.21.5') + return class ChunkColumn extends CommonChunkColumn { + static get section () { return ChunkSection } + constructor (options) { + super(mcData) + this.minY = options?.minY ?? CAVES_UPDATE_MIN_Y + this.worldHeight = options?.worldHeight ?? CAVES_UPDATE_WORLD_HEIGHT + this.numSections = this.worldHeight >> 4 + this.maxBitsPerBlock = neededBits(Object.values(mcData.blocks).reduce((high, block) => Math.max(high, block.maxStateId), 0)) + this.maxBitsPerBiome = neededBits(Object.values(mcData.biomes).length) + + this.sections = options?.sections ?? Array.from( + { length: this.numSections }, _ => new ChunkSection({ noSizePrefix, maxBitsPerBlock: this.maxBitsPerBlock }) + ) + this.biomes = options?.biomes ?? Array.from( + { length: this.numSections }, _ => new BiomeSection({ noSizePrefix }) + ) + + this.skyLightMask = options?.skyLightMask ?? new BitArray({ + bitsPerValue: 1, + capacity: this.numSections + 2 + }) + this.emptySkyLightMask = options?.emptySkyLightMask ?? new BitArray({ + bitsPerValue: 1, + capacity: this.numSections + 2 + }) + this.skyLightSections = options?.skyLightSections ?? Array( + this.numSections + 2 + ).fill(null) + + this.blockLightMask = options?.blockLightMask ?? new BitArray({ + bitsPerValue: 1, + capacity: this.numSections + 2 + }) + this.emptyBlockLightMask = options?.emptyBlockLightMask ?? new BitArray({ + bitsPerValue: 1, + capacity: this.numSections + 2 + }) + this.blockLightSections = options?.blockLightSections ?? Array( + this.numSections + 2 + ).fill(null) + this.blockEntities = options?.blockEntities ?? {} + } + + toJson () { + return JSON.stringify({ + worldHeight: this.worldHeight, + minY: this.minY, + + sections: this.sections.map(section => section.toJson()), + biomes: this.biomes.map(biome => biome.toJson()), + + skyLightMask: this.skyLightMask.toLongArray(), + emptySkyLightMask: this.emptySkyLightMask.toLongArray(), + skyLightSections: this.skyLightSections.map(section => section === null ? null : section.toJson()), + + blockLightMask: this.blockLightMask.toLongArray(), + emptyBlockLightMask: this.emptyBlockLightMask.toLongArray(), + blockLightSections: this.blockLightSections.map(section => section === null ? null : section.toJson()), + + blockEntities: this.blockEntities + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + return new ChunkColumn({ + worldHeight: parsed.worldHeight, + minY: parsed.minY, + + sections: parsed.sections.map(s => ChunkSection.fromJson(s)), + biomes: parsed.biomes.map(s => BiomeSection.fromJson(s)), + blockEntities: parsed.blockEntities, + + skyLightMask: BitArray.fromLongArray(parsed.skyLightMask, 1), + emptySkyLightMask: BitArray.fromLongArray(parsed.emptyBlockLightMask, 1), + skyLightSections: parsed.skyLightSections.map(s => s === null ? null : BitArray.fromJson(s)), + + blockLightMask: BitArray.fromLongArray(parsed.blockLightMask, 1), + emptyBlockLightMask: BitArray.fromLongArray(parsed.emptySkyLightMask, 1), + blockLightSections: parsed.blockLightSections.map(s => s === null ? null : BitArray.fromJson(s)) + }) + } + + initialize (func) { + const p = { x: 0, y: 0, z: 0 } + const maxY = this.worldHeight + this.minY + for (p.y = this.minY; p.y < maxY; p.y++) { + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + const block = func(p.x, p.y, p.z) + if (block !== null) { this.setBlock(p, block) } + } + } + } + } + + getBlock (pos) { + const stateId = this.getBlockStateId(pos) + const biome = this.getBiome(pos) + const block = Block.fromStateId(stateId, biome) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + block.entity = this.getBlockEntity(pos) + return block + } + + setBlock (pos, block) { + if (typeof block.stateId !== 'undefined') { + this.setBlockStateId(pos, block.stateId) + } + if (typeof block.biome !== 'undefined') { + this.setBiome(pos, block.biome.id) + } + if (typeof block.skyLight !== 'undefined') { + this.setSkyLight(pos, block.skyLight) + } + if (typeof block.light !== 'undefined') { + this.setBlockLight(pos, block.light) + } + // TODO: assert here if setting a block that should have an associated block entity + if (block.entity) { + this.setBlockEntity(pos, block.entity) + } else { + this.removeBlockEntity(pos) + } + } + + getBlockType (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].id + } + + getBlockData (pos) { + const blockStateId = this.getBlockStateId(pos) + return mcData.blocksByStateId[blockStateId].metadata + } + + getBlockStateId (pos) { + const section = this.sections[(pos.y - this.minY) >> 4] + return section ? section.get(toSectionPos(pos, this.minY)) : 0 + } + + getBlockLight (pos) { + const section = this.blockLightSections[getLightSectionIndex(pos, this.minY)] + return section ? section.get(getSectionBlockIndex(pos, this.minY)) : 0 + } + + getSkyLight (pos) { + const section = this.skyLightSections[getLightSectionIndex(pos, this.minY)] + return section ? section.get(getSectionBlockIndex(pos, this.minY)) : 0 + } + + getBiome (pos) { + const biome = this.biomes[(pos.y - this.minY) >> 4] + return biome ? biome.get(toBiomePos(pos, this.minY)) : 0 + } + + setBlockType (pos, id) { + this.setBlockStateId(pos, mcData.blocks[id].minStateId) + } + + setBlockData (pos, data) { + this.setBlockStateId(pos, mcData.blocksByStateId[this.getBlockStateId(pos)].minStateId + data) + } + + setBlockStateId (pos, stateId) { + const section = this.sections[(pos.y - this.minY) >> 4] + if (section) { section.set(toSectionPos(pos, this.minY), stateId) } + } + + setBlockLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos, this.minY) + let section = this.blockLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + if (sectionIndex > this.blockLightMask.capacity) { + this.blockLightMask = this.blockLightMask.resize(sectionIndex) + } + this.blockLightMask.set(sectionIndex, 1) + this.blockLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos, this.minY), light) + } + + setSkyLight (pos, light) { + const sectionIndex = getLightSectionIndex(pos, this.minY) + let section = this.skyLightSections[sectionIndex] + + if (section === null) { + if (light === 0) { + return + } + section = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + this.skyLightMask.set(sectionIndex, 1) + this.skyLightSections[sectionIndex] = section + } + + section.set(getSectionBlockIndex(pos, this.minY), light) + } + + setBiome (pos, biomeId) { + const biome = this.biomes[(pos.y - this.minY) >> 4] + if (biome) { biome.set(toBiomePos(pos), biomeId) } + } + + getMask () { + return undefined + } + + dump () { + const smartBuffer = new SmartBuffer() + for (let i = 0; i < this.numSections; ++i) { + this.sections[i].write(smartBuffer) + this.biomes[i].write(smartBuffer) + } + return smartBuffer.toBuffer() + } + + loadBiomes (biomes) { + } + + dumpBiomes (biomes) { + return undefined + } + + load (data) { + const reader = SmartBuffer.fromBuffer(data) + for (let i = 0; i < this.numSections; ++i) { + this.sections[i] = ChunkSection.read(reader, this.maxBitsPerBlock, noSizePrefix) + this.biomes[i] = BiomeSection.read(reader, this.maxBitsPerBiome, noSizePrefix) + } + } + + loadParsedLight (skyLight, blockLight, skyLightMask, blockLightMask, emptySkyLightMask, emptyBlockLightMask) { + function readSection (sections, data, lightMask, pLightMask, emptyMask, pEmptyMask) { + let currentSectionIndex = 0 + const incomingLightMask = BitArray.fromLongArray(pLightMask, 1) + const incomingEmptyMask = BitArray.fromLongArray(pEmptyMask, 1) + + for (let y = 0; y < sections.length; y++) { + const isEmpty = incomingEmptyMask.get(y) + if (!incomingLightMask.get(y) && !isEmpty) { continue } + + emptyMask.set(y, isEmpty) + lightMask.set(y, 1 - isEmpty) + + const bitArray = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }) + sections[y] = bitArray + + if (!isEmpty) { + const sectionReader = Buffer.from(data[currentSectionIndex++]) + bitArray.readBuffer(SmartBuffer.fromBuffer(sectionReader)) + } + } + } + + readSection(this.skyLightSections, skyLight, this.skyLightMask, skyLightMask, this.emptySkyLightMask, emptySkyLightMask) + readSection(this.blockLightSections, blockLight, this.blockLightMask, blockLightMask, this.emptyBlockLightMask, emptyBlockLightMask) + } + + _loadBlockLightNibbles (y, buffer) { + if (buffer.length !== 2048) throw new Error('Invalid light nibble buffer length ' + buffer.length) + const minCY = Math.abs(this.minY >> 4) + 1 // minCY + 1 extra layer below + this.blockLightMask.set(y + minCY, 1) + this.blockLightSections[y + minCY] = new BitArray({ + bitsPerValue: 4, + capacity: 4096, + data: new Int8Array(buffer).buffer + }) + } + + _loadSkyLightNibbles (y, buffer) { + if (buffer.length !== 2048) throw new Error('Invalid light nibble buffer length: ' + buffer.length) + const minCY = Math.abs(this.minY >> 4) + 1 // minCY + 1 extra layer below + this.skyLightMask.set(y + minCY, 1) + this.skyLightSections[y + minCY] = new BitArray({ + bitsPerValue: 4, + capacity: 4096, + data: new Int8Array(buffer).buffer + }) + } + + // Loads an disk serialized chunk + loadSection (y, blockStates, biomes, blockLight, skyLight) { + const minCY = Math.abs(this.minY >> 4) + const raiseUnknownBlock = block => { throw new Error(`Failed to map ${JSON.stringify(block)} to a block state ID`) } + // TOOD: we should probably not fail, but because we use numerical biome IDs in pchunk we need to fail + const raiseUnknownBiome = biome => { throw new Error(`Failed to map ${JSON.stringify(biome)} to a biome ID`) } + this.sections[y + minCY] = ChunkSection.fromLocalPalette({ + noSizePrefix, + data: BitArray.fromLongArray(blockStates.data || {}, blockStates.bitsPerBlock), + palette: blockStates.palette + .map(e => Block.fromProperties(e.Name.replace('minecraft:', ''), e.Properties || {}) ?? raiseUnknownBlock(e)) + .map(e => e.stateId) + }) + + this.biomes[y + minCY] = BiomeSection.fromLocalPalette({ + noSizePrefix, + data: BitArray.fromLongArray(biomes.data || {}, biomes.bitsPerBiome), + palette: biomes.palette + .map(e => mcData.biomesByName[e.replace('minecraft:', '')] ?? raiseUnknownBiome(e)) + .map(e => e.id) + }) + + if (blockLight) this._loadBlockLightNibbles(y, blockLight) + if (skyLight) this._loadSkyLightNibbles(y, skyLight) + } + + dumpLight () { + const skyLight = [] + const blockLight = [] + + this.skyLightSections.forEach((section, index) => { + if (section !== null && this.skyLightMask.get(index)) { + const smartBuffer = new SmartBuffer() + section.writeBuffer(smartBuffer) + skyLight.push(Uint8Array.from(smartBuffer.toBuffer())) + } + }) + + this.blockLightSections.forEach((section, index) => { + if (section !== null && this.blockLightMask.get(index)) { + const smartBuffer = new SmartBuffer() + section.writeBuffer(smartBuffer) + blockLight.push(Uint8Array.from(smartBuffer.toBuffer())) + } + }) + + return { + skyLight, + blockLight, + skyLightMask: this.skyLightMask.toLongArray(), + blockLightMask: this.blockLightMask.toLongArray(), + emptySkyLightMask: this.emptySkyLightMask.toLongArray(), + emptyBlockLightMask: this.emptyBlockLightMask.toLongArray() + } + } + } +} + +function getLightSectionIndex (pos, minY) { + return Math.floor((pos.y - minY) / 16) + 1 +} + +function toBiomePos (pos, minY) { + return { x: pos.x >> 2, y: ((pos.y - minY) & 0xF) >> 2, z: pos.z >> 2 } +} + +function toSectionPos (pos, minY) { + return { x: pos.x, y: (pos.y - minY) & 0xF, z: pos.z } +} + +function getSectionBlockIndex (pos, minY) { + return (((pos.y - minY) & 15) << 8) | (pos.z << 4) | pos.x +} diff --git a/bridge/lib/prismarine-chunk/src/pc/1.18/chunk.js b/bridge/lib/prismarine-chunk/src/pc/1.18/chunk.js new file mode 100644 index 0000000..d7dd67c --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.18/chunk.js @@ -0,0 +1,7 @@ +function loader (registry) { + const Block = require('prismarine-block')(registry) + + return require('./ChunkColumn')(Block, registry) +} + +module.exports = loader diff --git a/bridge/lib/prismarine-chunk/src/pc/1.8/chunk.js b/bridge/lib/prismarine-chunk/src/pc/1.8/chunk.js new file mode 100644 index 0000000..f5903e6 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.8/chunk.js @@ -0,0 +1,244 @@ +const CommonChunkColumn = require('../common/CommonChunkColumn') +const Section = require('./section') +const Vec3 = require('vec3').Vec3 + +const BIOME_SIZE = 256 +const h = 256 +const { w, l, sh } = Section +const sectionCount = h >> 4 + +module.exports = (registry) => { + const Block = require('prismarine-block')(registry) + + return class Chunk extends CommonChunkColumn { + static get section () { return Section } + static get w () { return w } + static get l () { return l } + static get h () { return h } + static get version () { return registry.version } + + constructor () { + super(registry) + this.skyLightSent = true + + this.minY = 0 + this.worldHeight = h + + this.sections = new Array(sectionCount) + for (let i = 0; i < sectionCount; i++) { this.sections[i] = new Section() } + // alloc automatically zero initializes + this.biome = Buffer.alloc(BIOME_SIZE) + } + + toJson () { + return JSON.stringify({ + skyLightSent: this.skyLightSent, + biome: this.biome.toJSON(), + blockEntities: this.blockEntities, + sections: this.sections.map(section => section.toJson()) + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new Chunk() + chunk.skyLightSent = parsed.skyLightSent + chunk.biome = Buffer.from(parsed.biome) + chunk.blockEntities = parsed.blockEntities + chunk.sections = parsed.sections.map(s => Section.fromJson(s)) + return chunk + } + + initialize (iniFunc) { + let biome = 0 + for (let i = 0; i < sectionCount; i++) { + this.sections[i].initialize((x, y, z, n) => { + const block = iniFunc(x, y % sh, z, n) + if (block == null) { return } + if (y === 0 && sectionCount === 0) { + this.biome.writeUInt8(block.biome.id || 0, biome) + biome++ + } + return block + }) + } + } + + getBlock (pos) { + const block = new Block(this.getBlockType(pos), this.getBiome(pos), this.getBlockData(pos)) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + block.entity = this.getBlockEntity(pos) + return block + } + + setBlock (pos, block) { + if (exists(block.type)) { this.setBlockType(pos, block.type) } + if (exists(block.metadata)) { this.setBlockData(pos, block.metadata) } + if (exists(block.biome)) { this.setBiome(pos, block.biome.id) } + if (exists(block.skyLight) && this.skyLightSent) { this.setSkyLight(pos, block.skyLight) } + if (exists(block.light)) { this.setBlockLight(pos, block.light) } + if (block.entity) { + this.setBlockEntity(pos, block.entity) + } else { + this.removeBlockEntity(pos) + } + } + + _getSection (pos) { + return this.sections[pos.y >> 4] + } + + getBlockStateId (pos) { + const section = this._getSection(pos) + return section ? section.getBlockStateId(posInSection(pos)) : 0 + } + + getBlockType (pos) { + const section = this._getSection(pos) + return section ? section.getBlockType(posInSection(pos)) : 0 + } + + getBlockData (pos) { + const section = this._getSection(pos) + return section ? section.getBlockData(posInSection(pos)) : 0 + } + + getBlockLight (pos) { + const section = this._getSection(pos) + return section ? section.getBlockLight(posInSection(pos)) : 0 + } + + getSkyLight (pos) { + if (!this.skyLightSent) return 0 + const section = this._getSection(pos) + return section ? section.getSkyLight(posInSection(pos)) : 15 + } + + getBiome (pos) { + const cursor = getBiomeCursor(pos) + return this.biome.readUInt8(cursor) + } + + setBlockStateId (pos, stateId) { + const section = this._getSection(pos) + return section && section.setBlockStateId(posInSection(pos), stateId) + } + + setBlockType (pos, id) { + const data = this.getBlockData(pos) + this.setBlockStateId(pos, (id << 4) | data) + } + + setBlockData (pos, data) { + const id = this.getBlockType(pos) + this.setBlockStateId(pos, (id << 4) | data) + } + + setBlockLight (pos, light) { + const section = this._getSection(pos) + return section && section.setBlockLight(posInSection(pos), light) + } + + setSkyLight (pos, light) { + const section = this._getSection(pos) + return section && section.setSkyLight(posInSection(pos), light) + } + + setBiome (pos, biome) { + const cursor = getBiomeCursor(pos) + this.biome.writeUInt8(biome, cursor) + } + + // These methods do nothing, and are present only for API compatibility + dumpBiomes () { + + } + + dumpLight () { + + } + + loadLight () { + + } + + loadBiomes () { + + } + + dump (bitMap = 0xFFFF, skyLightSent = true) { + const SECTION_SIZE = Section.sectionSize(this.skyLightSent && skyLightSent) + + const { chunkIncluded, chunkCount } = parseBitMap(bitMap) + const bufferLength = chunkCount * SECTION_SIZE + BIOME_SIZE + const buffer = Buffer.alloc(bufferLength) + let offset = 0 + let offsetLight = w * l * sectionCount * chunkCount * 2 + let offsetSkyLight = (this.skyLightSent && skyLightSent) ? w * l * sectionCount * chunkCount / 2 * 5 : undefined + for (let i = 0; i < sectionCount; i++) { + if (chunkIncluded[i]) { + offset += this.sections[i].dump().copy(buffer, offset, 0, w * l * sh * 2) + offsetLight += this.sections[i].dump().copy(buffer, offsetLight, w * l * sh * 2, w * l * sh * 2 + w * l * sh / 2) + if (this.skyLightSent && skyLightSent) offsetSkyLight += this.sections[i].dump().copy(buffer, offsetSkyLight, w * l * sh / 2 * 5, w * l * sh / 2 * 5 + w * l * sh / 2) + } + } + this.biome.copy(buffer, w * l * sectionCount * chunkCount * ((this.skyLightSent && skyLightSent) ? 3 : 5 / 2)) + return buffer + } + + load (data, bitMap = 0xFFFF, skyLightSent = true, fullChunk = true) { + if (!Buffer.isBuffer(data)) { throw (new Error('Data must be a buffer')) } + + this.skyLightSent = skyLightSent + + const SECTION_SIZE = Section.sectionSize(skyLightSent) + + const { chunkIncluded, chunkCount } = parseBitMap(bitMap) + let offset = 0 + let offsetLight = w * l * sectionCount * chunkCount * 2 + let offsetSkyLight = (this.skyLightSent) ? w * l * sectionCount * chunkCount / 2 * 5 : undefined + for (let i = 0; i < sectionCount; i++) { + if (chunkIncluded[i]) { + const sectionBuffer = Buffer.alloc(SECTION_SIZE) + offset += data.copy(sectionBuffer, 0, offset, offset + w * l * sh * 2) + offsetLight += data.copy(sectionBuffer, w * l * sh * 2, offsetLight, offsetLight + w * l * sh / 2) + if (this.skyLightSent) offsetSkyLight += data.copy(sectionBuffer, w * l * sh * 5 / 2, offsetSkyLight, offsetSkyLight + w * l * sh / 2) + this.sections[i].load(sectionBuffer, skyLightSent) + } + } + if (fullChunk) { + data.copy(this.biome, 0, w * l * sectionCount * chunkCount * (skyLightSent ? 3 : 5 / 2)) + } + + const expectedSize = SECTION_SIZE * chunkCount + (fullChunk ? w * l : 0) + if (data.length !== expectedSize) { throw (new Error(`Data buffer not correct size (was ${data.length}, expected ${expectedSize})`)) } + } + + getMask () { + return 0xFFFF + } + } +} + +const exists = function (val) { + return val !== undefined +} + +const getBiomeCursor = function (pos) { + return (pos.z * w) + pos.x +} + +function posInSection (pos) { + return pos.modulus(new Vec3(w, l, sh)) +} + +function parseBitMap (bitMap) { + const chunkIncluded = new Array(sectionCount) + let chunkCount = 0 + for (let y = 0; y < sectionCount; ++y) { + chunkIncluded[y] = bitMap & (1 << y) + if (chunkIncluded[y]) chunkCount++ + } + return { chunkIncluded, chunkCount } +} diff --git a/bridge/lib/prismarine-chunk/src/pc/1.8/section.js b/bridge/lib/prismarine-chunk/src/pc/1.8/section.js new file mode 100644 index 0000000..418fcd0 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.8/section.js @@ -0,0 +1,143 @@ +const { readUInt4LE, writeUInt4LE } = require('uint4') + +const w = 16 +const l = 16 +const sh = 16// section height + +const getArrayPosition = function (pos) { + return pos.x + w * (pos.z + l * pos.y) +} + +const getBlockCursor = function (pos) { + return getArrayPosition(pos) * 2.0 +} + +const getBlockLightCursor = function (pos) { + return getArrayPosition(pos) * 0.5 + w * l * sh * 2 +} + +const getSkyLightCursor = function (pos) { + return getArrayPosition(pos) * 0.5 + w * l * sh / 2 * 5 +} + +class Section { + constructor (skyLightSent = true) { + const SECTION_SIZE = Section.sectionSize(skyLightSent) + + this.data = Buffer.alloc(SECTION_SIZE) + this.data.fill(0) + } + + toJson () { + return this.data.toJSON() + } + + static fromJson (j) { + const section = new Section() + section.data = Buffer.from(j) + return section + } + + static sectionSize (skyLightSent = true) { + return (w * l * sh) * (skyLightSent ? 3 : 5 / 2) + } + + initialize (iniFunc) { + const skylight = w * l * sh / 2 * 5 + const light = w * l * sh * 2 + let n = 0 + for (let y = 0; y < sh; y++) { + for (let z = 0; z < w; z++) { + for (let x = 0; x < l; x++, n++) { + const block = iniFunc(x, y, z, n) + if (block == null) { continue } + this.data.writeUInt16LE(block.type << 4 | block.metadata, n * 2) + writeUInt4LE(this.data, block.light, n * 0.5 + light) + writeUInt4LE(this.data, block.skyLight, n * 0.5 + skylight) + } + } + } + } + + getBiomeColor (pos) { + return { + r: 0, + g: 0, + b: 0 + } + } + + setBiomeColor (pos, r, g, b) { + + } + + getBlockStateId (pos) { + const cursor = getBlockCursor(pos) + return this.data.readUInt16LE(cursor) + } + + getBlockType (pos) { + const cursor = getBlockCursor(pos) + return this.data.readUInt16LE(cursor) >> 4 + } + + getBlockData (pos) { + const cursor = getBlockCursor(pos) + return this.data.readUInt16LE(cursor) & 15 + } + + getBlockLight (pos) { + const cursor = getBlockLightCursor(pos) + return readUInt4LE(this.data, cursor) + } + + getSkyLight (pos) { + const cursor = getSkyLightCursor(pos) + return readUInt4LE(this.data, cursor) + } + + setBlockStateId (pos, stateId) { + const cursor = getBlockCursor(pos) + this.data.writeUInt16LE(stateId, cursor) + } + + setBlockType (pos, id) { + const cursor = getBlockCursor(pos) + const data = this.getBlockData(pos) + this.data.writeUInt16LE((id << 4) | data, cursor) + } + + setBlockData (pos, data) { + const cursor = getBlockCursor(pos) + const id = this.getBlockType(pos) + this.data.writeUInt16LE((id << 4) | data, cursor) + } + + setBlockLight (pos, light) { + const cursor = getBlockLightCursor(pos) + writeUInt4LE(this.data, light, cursor) + } + + setSkyLight (pos, light) { + const cursor = getSkyLightCursor(pos) + writeUInt4LE(this.data, light, cursor) + } + + dump () { + return this.data + } + + load (data, skyLightSent = true) { + const SECTION_SIZE = Section.sectionSize(skyLightSent) + + if (!Buffer.isBuffer(data)) { throw (new Error('Data must be a buffer')) } + if (data.length !== SECTION_SIZE) { throw (new Error(`Data buffer not correct size (was ${data.length}, expected ${SECTION_SIZE})`)) } + this.data = data + } +} + +Section.w = w +Section.l = l +Section.sh = sh + +module.exports = Section diff --git a/bridge/lib/prismarine-chunk/src/pc/1.9/ChunkColumn.js b/bridge/lib/prismarine-chunk/src/pc/1.9/ChunkColumn.js new file mode 100644 index 0000000..1de1a3c --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.9/ChunkColumn.js @@ -0,0 +1,274 @@ +const SmartBuffer = require('smart-buffer').SmartBuffer +const ChunkSection = require('./ChunkSection') +const constants = require('../common/constants') +const BitArray = require('../common/BitArray') +const varInt = require('../common/varInt') +const CommonChunkColumn = require('../common/CommonChunkColumn') +const neededBits = require('../common/neededBits') + +const exists = val => val !== undefined + +// wrap with func to provide version specific Block +module.exports = (Block, mcData) => { + return class ChunkColumn extends CommonChunkColumn { + static get section () { return ChunkSection } + constructor () { + super(mcData) + this.sectionMask = 0 + this.skyLightSent = true + this.sections = Array(constants.NUM_SECTIONS).fill(null) + this.biomes = Array( + constants.SECTION_WIDTH * constants.SECTION_WIDTH + ).fill(1) + this.maxBitsPerBlock = neededBits(Object.values(mcData.blocks).reduce((high, block) => Math.max(high, block.maxStateId), 0)) + } + + toJson () { + return JSON.stringify({ + biomes: this.biomes, + blockEntities: this.blockEntities, + sectionMask: this.sectionMask, + sections: this.sections.map(section => section === null ? null : section.toJson()) + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + const chunk = new ChunkColumn() + chunk.biomes = parsed.biomes + chunk.blockEntities = parsed.blockEntities + chunk.sectionMask = parsed.sectionMask + chunk.sections = parsed.sections.map(s => s === null ? null : ChunkSection.fromJson(s)) + return chunk + } + + initialize (func) { + const p = { x: 0, y: 0, z: 0 } + for (p.y = 0; p.y < constants.CHUNK_HEIGHT; p.y++) { + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + const block = func(p.x, p.y, p.z) + if (block === null) { + continue + } + this.setBlock(p, block) + } + } + } + } + + getBlock (pos) { + const block = new Block(this.getBlockType(pos), this.getBiome(pos), this.getBlockData(pos)) + block.light = this.getBlockLight(pos) + block.skyLight = this.getSkyLight(pos) + block.entity = this.getBlockEntity(pos) + return block + } + + setBlock (pos, block) { + if (exists(block.type)) { this.setBlockType(pos, block.type) } + if (exists(block.metadata)) { this.setBlockData(pos, block.metadata) } + if (exists(block.biome)) { this.setBiome(pos, block.biome.id) } + if (exists(block.skyLight) && this.skyLightSent) { this.setSkyLight(pos, block.skyLight) } + if (exists(block.light)) { this.setBlockLight(pos, block.light) } + if (block.entity) { + this.setBlockEntity(pos, block.entity) + } else { + this.removeBlockEntity(pos) + } + } + + getBlockType (pos) { + return this.getBlockStateId(pos) >> 4 + } + + getBlockData (pos) { + return this.getBlockStateId(pos) & 15 + } + + getBlockStateId (pos) { + const section = this.sections[pos.y >> 4] + return section ? section.getBlock(toSectionPos(pos)) : 0 + } + + getBlockLight (pos) { + const section = this.sections[pos.y >> 4] + return section ? section.getBlockLight(toSectionPos(pos)) : 15 + } + + getSkyLight (pos) { + const section = this.sections[pos.y >> 4] + return section ? section.getSkyLight(toSectionPos(pos)) : 15 + } + + getBiome (pos) { + return this.biomes[getBiomeIndex(pos)] + } + + setBlockType (pos, id) { + const data = this.getBlockData(pos) + this.setBlockStateId(pos, (id << 4) | data) + } + + setBlockData (pos, data) { + const id = this.getBlockType(pos) + this.setBlockStateId(pos, (id << 4) | data) + } + + setBlockStateId (pos, stateId) { + const sectionIndex = pos.y >> 4 + if (sectionIndex < 0 || sectionIndex >= 16) return + + let section = this.sections[sectionIndex] + if (!section) { + // if it's air + if (stateId === 0) { + return + } + section = new ChunkSection({ + maxBitsPerBlock: this.maxBitsPerBlock + }) + this.sectionMask |= 1 << sectionIndex + this.sections[sectionIndex] = section + } + + section.setBlock(toSectionPos(pos), stateId) + } + + setBlockLight (pos, light) { + const section = this.sections[pos.y >> 4] + return section && section.setBlockLight(toSectionPos(pos), light) + } + + setSkyLight (pos, light) { + const section = this.sections[pos.y >> 4] + return section && section.setSkyLight(toSectionPos(pos), light) + } + + setBiome (pos, biome) { + this.biomes[getBiomeIndex(pos)] = biome + } + + getMask () { + return this.sectionMask + } + + // These methods do nothing, and are present only for API compatibility + dumpBiomes () { + + } + + dumpLight () { + + } + + loadLight () { + + } + + loadBiomes () { + + } + + dump () { + const smartBuffer = new SmartBuffer() + this.sections.forEach((section, i) => { + if (section !== null && !section.isEmpty()) { + section.write(smartBuffer) + } + }) + + // write biome data + this.biomes.forEach(biome => { + smartBuffer.writeUInt8(biome) + }) + + return smartBuffer.toBuffer() + } + + load (data, bitMap = 0xffff, skyLightSent = true, fullChunk = true) { + // make smartbuffer from node buffer + // so that we doesn't need to maintain a cursor + const reader = SmartBuffer.fromBuffer(data) + + this.skyLightSent = skyLightSent + this.sectionMask |= bitMap + for (let y = 0; y < constants.NUM_SECTIONS; ++y) { + // does `data` contain this chunk? + if (!((bitMap >> y) & 1)) { + // we can skip write a section if it isn't requested + continue + } + + // keep temporary palette + let palette + let skyLight + + // get number of bits a palette item use + const bitsPerBlock = reader.readUInt8() + + // check if the section uses a section palette + if (bitsPerBlock <= constants.MAX_BITS_PER_BLOCK) { + palette = [] + // get number of palette items + const numPaletteItems = varInt.read(reader) + + // save each palette item + for (let i = 0; i < numPaletteItems; ++i) { + palette.push(varInt.read(reader)) + } + } else { + // remove the 0 length signifying the missing palette array + varInt.read(reader) + // global palette is used + palette = null + } + + // number of items in data array + const dataArray = new BitArray({ + bitsPerValue: bitsPerBlock > constants.MAX_BITS_PER_BLOCK ? this.maxBitsPerBlock : bitsPerBlock, + capacity: 4096 + }).readBuffer(reader, varInt.read(reader) * 2) + + const blockLight = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + + if (skyLightSent) { + skyLight = new BitArray({ + bitsPerValue: 4, + capacity: 4096 + }).readBuffer(reader) + } + + const section = new ChunkSection({ + data: dataArray, + palette, + blockLight, + ...(skyLightSent ? { skyLight } : { skyLight: null }), + maxBitsPerBlock: this.maxBitsPerBlock + }) + this.sections[y] = section + } + + // read biomes + if (fullChunk) { + const p = { x: 0, y: 0, z: 0 } + for (p.z = 0; p.z < constants.SECTION_WIDTH; p.z++) { + for (p.x = 0; p.x < constants.SECTION_WIDTH; p.x++) { + this.setBiome(p, reader.readUInt8()) + } + } + } + } + } +} + +function getBiomeIndex (pos) { + return (pos.z * 16) | pos.x +} + +function toSectionPos (pos) { + return { x: pos.x, y: pos.y & 15, z: pos.z } +} diff --git a/bridge/lib/prismarine-chunk/src/pc/1.9/ChunkSection.js b/bridge/lib/prismarine-chunk/src/pc/1.9/ChunkSection.js new file mode 100644 index 0000000..80ac01e --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.9/ChunkSection.js @@ -0,0 +1,199 @@ +const BitArray = require('../common/BitArray') +const neededBits = require('../common/neededBits') +const constants = require('../common/constants') +const varInt = require('../common/varInt') +const GLOBAL_BITS_PER_BLOCK = 13 + +function getBlockIndex (pos) { + return (pos.y << 8) | (pos.z << 4) | pos.x +} + +class ChunkSection { + constructor (options = {}) { + if (options === null) { + return + } + + if (typeof options.solidBlockCount === 'undefined') { + options.solidBlockCount = 0 + if (options.data) { + for (let i = 0; i < constants.BLOCK_SECTION_VOLUME; i++) { + if (options.data.get(i) !== 0) { + options.solidBlockCount += 1 + } + } + } + } + + if (!options.data) { + options.data = new BitArray({ + bitsPerValue: 4, + capacity: constants.BLOCK_SECTION_VOLUME + }) + } + + if (options.palette === undefined) { // dont create palette if its null + options.palette = [0] + } + + if (!options.blockLight) { + options.blockLight = new BitArray({ + bitsPerValue: 4, + capacity: constants.BLOCK_SECTION_VOLUME + }) + } + + if (options.skyLight === undefined) { // dont create skylight if its null + options.skyLight = new BitArray({ + bitsPerValue: 4, + capacity: constants.BLOCK_SECTION_VOLUME + }) + } + + this.data = options.data + this.palette = options.palette + this.isDirty = false + this.blockLight = options.blockLight + this.skyLight = options.skyLight + this.solidBlockCount = options.solidBlockCount + this.maxBitsPerBlock = options.maxBitsPerBlock || GLOBAL_BITS_PER_BLOCK + } + + toJson () { + return JSON.stringify({ + data: this.data.toJson(), + palette: this.palette, + isDirty: this.isDirty, + blockLight: this.blockLight.toJson(), + skyLight: this.skyLight ? this.skyLight.toJson() : this.skyLight, + solidBlockCount: this.solidBlockCount + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + return new ChunkSection({ + data: BitArray.fromJson(parsed.data), + palette: parsed.palette, + blockLight: BitArray.fromJson(parsed.blockLight), + skyLight: parsed.skyLight ? BitArray.fromJson(parsed.skyLight) : parsed.skyLight, + solidBlockCount: parsed.solidBlockCount + }) + } + + getBlock (pos) { + // index in palette or block id + // depending on if the global palette or the section palette is used + let stateId = this.data.get(getBlockIndex(pos)) + + if (this.palette !== null) { + stateId = this.palette[stateId] + } + + return stateId + } + + setBlock (pos, stateId) { + const blockIndex = getBlockIndex(pos) + let palettedIndex + if (this.palette !== null) { + // if necessary, add the block to the palette + const indexInPalette = this.palette.indexOf(stateId) // binarySearch(this.palette, stateId, cmp) + if (indexInPalette >= 0) { + // block already in our palette + palettedIndex = indexInPalette + } else { + // get new block palette index + this.palette.push(stateId) + palettedIndex = this.palette.length - 1 + + // check if resize is necessary + const bitsPerValue = neededBits(palettedIndex) + + // if new block requires more bits than the current data array + if (bitsPerValue > this.data.getBitsPerValue()) { + // is value still enough for section palette + if (bitsPerValue <= constants.MAX_BITS_PER_BLOCK) { + this.data = this.data.resizeTo(bitsPerValue) + } else { + // switches to the global palette + const newData = new BitArray({ + bitsPerValue: this.maxBitsPerBlock, + capacity: constants.BLOCK_SECTION_VOLUME + }) + for (let i = 0; i < constants.BLOCK_SECTION_VOLUME; i++) { + const stateId = this.palette[this.data.get(i)] + newData.set(i, stateId) + } + + this.palette = null + palettedIndex = stateId + this.data = newData + } + } + } + } else { + // uses global palette + palettedIndex = stateId + } + + const oldBlock = this.getBlock(pos) + if (stateId === 0 && oldBlock !== 0) { + this.solidBlockCount -= 1 + } else if (stateId !== 0 && oldBlock === 0) { + this.solidBlockCount += 1 + } + + this.data.set(blockIndex, palettedIndex) + } + + getBlockLight (pos) { + return this.blockLight.get(getBlockIndex(pos)) + } + + getSkyLight (pos) { + return this.skyLight ? this.skyLight.get(getBlockIndex(pos)) : 0 + } + + setBlockLight (pos, light) { + return this.blockLight.set(getBlockIndex(pos), light) + } + + setSkyLight (pos, light) { + return this.skyLight ? this.skyLight.set(getBlockIndex(pos), light) : 0 + } + + isEmpty () { + return this.solidBlockCount === 0 + } + + // writes the complete section into a smart buffer object + write (smartBuffer) { + smartBuffer.writeUInt8(this.data.getBitsPerValue()) + + // write palette + if (this.palette !== null) { + varInt.write(smartBuffer, this.palette.length) + this.palette.forEach(paletteElement => { + varInt.write(smartBuffer, paletteElement) + }) + } else { + // write 0 length for missing palette + varInt.write(smartBuffer, 0) + } + + // write block data + varInt.write(smartBuffer, this.data.length()) + this.data.writeBuffer(smartBuffer) + + // write block light data + this.blockLight.writeBuffer(smartBuffer) + + if (this.skyLight !== null) { + // write sky light data + this.skyLight.writeBuffer(smartBuffer) + } + } +} + +module.exports = ChunkSection diff --git a/bridge/lib/prismarine-chunk/src/pc/1.9/chunk.js b/bridge/lib/prismarine-chunk/src/pc/1.9/chunk.js new file mode 100644 index 0000000..b5e86c7 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/1.9/chunk.js @@ -0,0 +1,12 @@ +const constants = require('../common/constants') + +function loader (registry) { + const Block = require('prismarine-block')(registry) + const Chunk = require('./ChunkColumn')(Block, registry) + // expose for test purposes + Chunk.h = constants.CHUNK_HEIGHT + Chunk.version = registry.version + return Chunk +} + +module.exports = loader diff --git a/bridge/lib/prismarine-chunk/src/pc/common/BitArray.js b/bridge/lib/prismarine-chunk/src/pc/common/BitArray.js new file mode 100644 index 0000000..8faa4dc --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/BitArray.js @@ -0,0 +1,161 @@ +// const assert = require('assert') +const neededBits = require('./neededBits') + +class BitArray { + constructor (options) { + if (options === null) { + return + } + // assert(options.bitsPerValue > 0, 'bits per value must at least 1') + // assert(options.bitsPerValue <= 32, 'bits per value exceeds 32') + + if (!options.data) { + options.data = new Uint32Array(Math.ceil((options.capacity * options.bitsPerValue) / 64) * 2) + } + const valueMask = (1 << options.bitsPerValue) - 1 + + this.data = options.data.buffer ? new Uint32Array(options.data.buffer) : Uint32Array.from(options.data) + this.capacity = options.capacity + this.bitsPerValue = options.bitsPerValue + this.valueMask = valueMask + } + + toJson () { + return JSON.stringify({ + data: Array.from(this.data), + capacity: this.capacity, + bitsPerValue: this.bitsPerValue, + valueMask: this.valueMask + }) + } + + static fromJson (j) { + return new BitArray(JSON.parse(j)) + } + + toArray () { + const array = [] + for (let i = 0; i < this.capacity; i++) { + array.push(this.get(i)) + } + return array + } + + static fromArray (array, bitsPerValue) { + const data = [] + let i = 0 + let curLong = 0 + let curBit = 0 + while (i < array.length) { + curLong |= array[i] << curBit + curBit += bitsPerValue + if (curBit > 32) { + data.push(curLong & 0xffffffff) + curBit -= 32 + curLong = array[i] >>> (bitsPerValue - curBit) + } + i++ + } + if (curBit > 0) { + data.push(curLong) + } + const bitarray = new BitArray(null) + bitarray.data = data + bitarray.capacity = array.length + bitarray.bitsPerValue = bitsPerValue + bitarray.valueMask = (1 << bitsPerValue) - 1 + return bitarray + } + + get (index) { + // assert(index >= 0 && index < this.capacity, 'index is out of bounds') + + const bitIndex = index * this.bitsPerValue + const startLongIndex = bitIndex >>> 5 + const startLong = this.data[startLongIndex] + const indexInStartLong = bitIndex & 31 + let result = startLong >>> indexInStartLong + const endBitOffset = indexInStartLong + this.bitsPerValue + if (endBitOffset > 32) { + // Value stretches across multiple longs + const endLong = this.data[startLongIndex + 1] + result |= endLong << (32 - indexInStartLong) + } + return result & this.valueMask + } + + set (index, value) { + // assert(index >= 0 && index < this.capacity, 'index is out of bounds') + // assert(value <= this.valueMask, 'value does not fit into bits per value') + + const bitIndex = index * this.bitsPerValue + const startLongIndex = bitIndex >>> 5 + const indexInStartLong = bitIndex & 31 + + // Clear bits of this value first + this.data[startLongIndex] = + ((this.data[startLongIndex] & ~(this.valueMask << indexInStartLong)) | + ((value & this.valueMask) << indexInStartLong)) >>> 0 + const endBitOffset = indexInStartLong + this.bitsPerValue + if (endBitOffset > 32) { + // Value stretches across multiple longs + this.data[startLongIndex + 1] = + ((this.data[startLongIndex + 1] & + ~((1 << (endBitOffset - 32)) - 1)) | + (value >> (32 - indexInStartLong))) >>> 0 + } + } + + resizeTo (newBitsPerValue) { + // assert(newBitsPerValue > 0, 'bits per value must at least 1') + // assert(newBitsPerValue <= 32, 'bits per value exceeds 32') + + const newArr = new BitArray({ + bitsPerValue: newBitsPerValue, + capacity: this.capacity + }) + for (let i = 0; i < this.capacity; ++i) { + const value = this.get(i) + if (neededBits(value) > newBitsPerValue) { + throw new Error( + "existing value in BitArray can't fit in new bits per value" + ) + } + newArr.set(i, value) + } + + return newArr + } + + length () { + return this.data.length / 2 + } + + readBuffer (smartBuffer, size = this.data.length) { + if (size !== this.data.length) { + this.data = new Uint32Array(size) + this.readOffset = size * 4 + return + } + + for (let i = 0; i < this.data.length; i += 2) { + this.data[i + 1] = smartBuffer.readUInt32BE() + this.data[i] = smartBuffer.readUInt32BE() + } + return this + } + + writeBuffer (smartBuffer) { + for (let i = 0; i < this.data.length; i += 2) { + smartBuffer.writeUInt32BE(this.data[i + 1]) + smartBuffer.writeUInt32BE(this.data[i]) + } + return this + } + + getBitsPerValue () { + return this.bitsPerValue + } +} + +module.exports = BitArray diff --git a/bridge/lib/prismarine-chunk/src/pc/common/BitArrayNoSpan.js b/bridge/lib/prismarine-chunk/src/pc/common/BitArrayNoSpan.js new file mode 100644 index 0000000..8d40c9d --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/BitArrayNoSpan.js @@ -0,0 +1,217 @@ +// const assert = require('assert') +const neededBits = require('./neededBits') + +class BitArray { + constructor (options) { + if (options === null) { + return + } + // assert(options.bitsPerValue > 0, 'bits per value must at least 1') + // assert(options.bitsPerValue <= 32, 'bits per value exceeds 32') + + const valuesPerLong = Math.floor(64 / options.bitsPerValue) + const bufferSize = Math.ceil(options.capacity / valuesPerLong) * 2 + const valueMask = (1 << options.bitsPerValue) - 1 + + this.data = new Uint32Array(options?.data ?? bufferSize) + this.capacity = options.capacity + this.bitsPerValue = options.bitsPerValue + this.valuesPerLong = valuesPerLong + this.valueMask = valueMask + } + + toJson () { + return JSON.stringify({ + data: Array.from(this.data), + capacity: this.capacity, + bitsPerValue: this.bitsPerValue, + valuesPerLong: this.valuesPerLong, + valueMask: this.valueMask + }) + } + + static fromJson (j) { + return new BitArray(JSON.parse(j)) + } + + toArray () { + const array = [] + for (let i = 0; i < this.capacity; i++) { + array.push(this.get(i)) + } + return array + } + + static fromArray (array, bitsPerValue) { + const bitarray = new BitArray({ + capacity: array.length, + bitsPerValue + }) + for (let i = 0; i < array.length; i++) { + bitarray.set(i, array[i]) + } + return bitarray + } + + // [[MSB, LSB]] + toLongArray () { + const array = [] + for (let i = 0; i < this.data.length; i += 2) { + array.push([this.data[i + 1] << 32 >> 32, this.data[i] << 32 >> 32]) + } + return array + } + + static fromLongArray (array, bitsPerValue) { + const bitArray = new BitArray({ + capacity: Math.floor(64 / bitsPerValue) * array.length, + bitsPerValue + }) + for (let i = 0; i < array.length; i++) { + const j = i * 2 + bitArray.data[j + 1] = array[i][0] + bitArray.data[j] = array[i][1] + } + return bitArray + } + + static or (a, b) { + const long = a.data.length > b.data.length ? a.data : b.data + const short = a.data.length > b.data.length ? b.data : a.data + const array = new Uint32Array(long.length) + array.set(long) + for (let i = 0; i < short.length; i++) { + array[i] |= short[i] + } + return new BitArray({ + data: array.buffer, + bitsPerValue: Math.max(a.bitsPerValue, b.bitsPerValue) + }) + } + + get (index) { + // assert(index >= 0 && index < this.capacity, 'index is out of bounds') + + const startLongIndex = Math.floor(index / this.valuesPerLong) + const indexInLong = (index - startLongIndex * this.valuesPerLong) * this.bitsPerValue + if (indexInLong >= 32) { + const indexInStartLong = indexInLong - 32 + const startLong = this.data[startLongIndex * 2 + 1] + return (startLong >>> indexInStartLong) & this.valueMask + } + const startLong = this.data[startLongIndex * 2] + const indexInStartLong = indexInLong + let result = startLong >>> indexInStartLong + const endBitOffset = indexInStartLong + this.bitsPerValue + if (endBitOffset > 32) { + // Value stretches across multiple longs + const endLong = this.data[startLongIndex * 2 + 1] + result |= endLong << (32 - indexInStartLong) + } + return result & this.valueMask + } + + set (index, value) { + // assert(index >= 0 && index < this.capacity, 'index is out of bounds') + // assert(value <= this.valueMask, 'value does not fit into bits per value') + + const startLongIndex = Math.floor(index / this.valuesPerLong) + const indexInLong = (index - startLongIndex * this.valuesPerLong) * this.bitsPerValue + if (indexInLong >= 32) { + const indexInStartLong = indexInLong - 32 + this.data[startLongIndex * 2 + 1] = + ((this.data[startLongIndex * 2 + 1] & ~(this.valueMask << indexInStartLong)) | + ((value & this.valueMask) << indexInStartLong)) >>> 0 + return + } + const indexInStartLong = indexInLong + + // Clear bits of this value first + this.data[startLongIndex * 2] = + ((this.data[startLongIndex * 2] & ~(this.valueMask << indexInStartLong)) | + ((value & this.valueMask) << indexInStartLong)) >>> 0 + const endBitOffset = indexInStartLong + this.bitsPerValue + if (endBitOffset > 32) { + // Value stretches across multiple longs + this.data[startLongIndex * 2 + 1] = + ((this.data[startLongIndex * 2 + 1] & + ~((1 << (endBitOffset - 32)) - 1)) | + (value >> (32 - indexInStartLong))) >>> 0 + } + } + + resize (newCapacity) { + const newArr = new BitArray({ + bitsPerValue: this.bitsPerValue, + capacity: newCapacity + }) + for (let i = 0; i < Math.min(newCapacity, this.capacity); ++i) { + newArr.set(i, this.get(i)) + } + + return newArr + } + + resizeTo (newBitsPerValue) { + // assert(newBitsPerValue > 0, 'bits per value must at least 1') + // assert(newBitsPerValue <= 32, 'bits per value exceeds 32') + + const newArr = new BitArray({ + bitsPerValue: newBitsPerValue, + capacity: this.capacity + }) + for (let i = 0; i < this.capacity; ++i) { + const value = this.get(i) + if (neededBits(value) > newBitsPerValue) { + throw new Error( + "existing value in BitArray can't fit in new bits per value" + ) + } + newArr.set(i, value) + } + + return newArr + } + + length () { + return this.data.length / 2 + } + + readBuffer (smartBuffer, size = this.data.length) { + // Validate size to prevent Infinity or invalid values + if (!Number.isFinite(size) || size < 0 || size > 1000000) { + console.warn(`Invalid size in BitArray.readBuffer: ${size}, using default size`) + size = this.data.length + } + + if (size !== this.data.length) { + this.data = new Uint32Array(size) + // Read the actual data instead of just skipping + for (let i = 0; i < size; i += 2) { + this.data[i + 1] = smartBuffer.readUInt32BE() + this.data[i] = smartBuffer.readUInt32BE() + } + return + } + + for (let i = 0; i < this.data.length; i += 2) { + this.data[i + 1] = smartBuffer.readUInt32BE() + this.data[i] = smartBuffer.readUInt32BE() + } + return this + } + + writeBuffer (smartBuffer) { + for (let i = 0; i < this.data.length; i += 2) { + smartBuffer.writeUInt32BE(this.data[i + 1]) + smartBuffer.writeUInt32BE(this.data[i]) + } + return this + } + + getBitsPerValue () { + return this.bitsPerValue + } +} + +module.exports = BitArray diff --git a/bridge/lib/prismarine-chunk/src/pc/common/CommonChunkColumn.js b/bridge/lib/prismarine-chunk/src/pc/common/CommonChunkColumn.js new file mode 100644 index 0000000..dd4c17a --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/CommonChunkColumn.js @@ -0,0 +1,71 @@ +const posKey = (pos) => `${pos.x},${pos.y},${pos.z}` + +class CommonChunkColumn { + constructor (registry) { + this.registry = registry + // Just a collection of deserialized NBTs + this.blockEntities = {} + this.minY = 0 + } + + // Some section getters, used by anvil-provider + + getSection (pos) { + return this.sections[(pos.y - this.minY) >> 4] + } + + getSectionAtIndex (index) { + const minY = this.minY >> 4 + return this.sections[index - minY] + } + + // Biomes + + getBiomeId (pos) { + return this.getBiome(pos) + } + + setBiomeId (pos, biome) { + this.setBiome(pos, biome) + } + + getBiomeData (pos) { + const biome = this.getBiome(pos) + return this.registry[biome] + } + + getBiomeColor (pos) { + const { color } = this.getBiomeData(pos) + const r = color >> 16 + const g = (color >> 8) & 0xff + const b = color & 0xff + return { r, g, b } + } + + setBiomeColor () { + throw new Error('Cannot change biome color, update the biome instead') + } + + // Block entities + + getBlockEntity (pos) { + return this.blockEntities[posKey(pos)] + } + + setBlockEntity (pos, tag) { + // Note: `pos` is relative to the chunk, not the world, tag's XYZ is + this.blockEntities[posKey(pos)] = tag + } + + removeBlockEntity (pos) { + delete this.blockEntities[posKey(pos)] + } + + loadBlockEntities (entities) { + for (const entity of entities) { + this.setBlockEntity({ x: entity.x.value >> 4, y: entity.y.value, z: entity.z.value >> 4 }, entity) + } + } +} + +module.exports = CommonChunkColumn diff --git a/bridge/lib/prismarine-chunk/src/pc/common/CommonChunkSection.js b/bridge/lib/prismarine-chunk/src/pc/common/CommonChunkSection.js new file mode 100644 index 0000000..5b48dd9 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/CommonChunkSection.js @@ -0,0 +1,155 @@ +const neededBits = require('./neededBits') +const constants = require('./constants') +const varInt = require('./varInt') + +function getBlockIndex (pos) { + return (pos.y << 8) | (pos.z << 4) | pos.x +} + +module.exports = BitArray => { + class ChunkSection { + constructor (options = {}) { + if (options === null) { + return + } + + if (typeof options.solidBlockCount === 'undefined') { + options.solidBlockCount = 0 + if (options.data) { + for (let i = 0; i < constants.BLOCK_SECTION_VOLUME; i++) { + if (options.data.get(i) !== 0) { + options.solidBlockCount += 1 + } + } + } + } + + if (!options.data) { + options.data = new BitArray({ + bitsPerValue: 4, + capacity: constants.BLOCK_SECTION_VOLUME + }) + } + + if (options.palette === undefined) { // dont create palette if its null + options.palette = [0] + } + + this.data = options.data + this.palette = options.palette + this.isDirty = false + this.solidBlockCount = options.solidBlockCount + this.maxBitsPerBlock = options.maxBitsPerBlock ?? constants.GLOBAL_BITS_PER_BLOCK + } + + toJson () { + return JSON.stringify({ + data: this.data.toJson(), + palette: this.palette, + isDirty: this.isDirty, + solidBlockCount: this.solidBlockCount + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + return new ChunkSection({ + data: BitArray.fromJson(parsed.data), + palette: parsed.palette, + solidBlockCount: parsed.solidBlockCount + }) + } + + getBlock (pos) { + // index in palette or block id + // depending on if the global palette or the section palette is used + let stateId = this.data.get(getBlockIndex(pos)) + + if (this.palette !== null) { + stateId = this.palette[stateId] + } + + return stateId + } + + setBlock (pos, stateId) { + const blockIndex = getBlockIndex(pos) + let palettedIndex + if (this.palette !== null) { + // if necessary, add the block to the palette + const indexInPalette = this.palette.indexOf(stateId) // binarySearch(this.palette, stateId, cmp) + if (indexInPalette >= 0) { + // block already in our palette + palettedIndex = indexInPalette + } else { + // get new block palette index + this.palette.push(stateId) + palettedIndex = this.palette.length - 1 + + // check if resize is necessary + const bitsPerValue = neededBits(palettedIndex) + + // if new block requires more bits than the current data array + if (bitsPerValue > this.data.getBitsPerValue()) { + // is value still enough for section palette + if (bitsPerValue <= constants.MAX_BITS_PER_BLOCK) { + this.data = this.data.resizeTo(bitsPerValue) + } else { + // switches to the global palette + const newData = new BitArray({ + bitsPerValue: this.maxBitsPerBlock, + capacity: constants.BLOCK_SECTION_VOLUME + }) + for (let i = 0; i < constants.BLOCK_SECTION_VOLUME; i++) { + const stateId = this.palette[this.data.get(i)] + newData.set(i, stateId) + } + + this.palette = null + palettedIndex = stateId + this.data = newData + } + } + } + } else { + // uses global palette + palettedIndex = stateId + } + + const oldBlock = this.getBlock(pos) + if (stateId === 0 && oldBlock !== 0) { + this.solidBlockCount -= 1 + } else if (stateId !== 0 && oldBlock === 0) { + this.solidBlockCount += 1 + } + + this.data.set(blockIndex, palettedIndex) + } + + isEmpty () { + return this.solidBlockCount === 0 + } + + // writes the complete section into a smart buffer object + write (smartBuffer) { + // write solid block count + smartBuffer.writeInt16BE(this.solidBlockCount) + + // write bits per block + smartBuffer.writeUInt8(this.data.getBitsPerValue()) + + // write palette + if (this.palette !== null) { + varInt.write(smartBuffer, this.palette.length) + this.palette.forEach(paletteElement => { + varInt.write(smartBuffer, paletteElement) + }) + } + + // write block data + varInt.write(smartBuffer, this.data.length()) + this.data.writeBuffer(smartBuffer) + } + } + return ChunkSection +} diff --git a/bridge/lib/prismarine-chunk/src/pc/common/PaletteBiome.js b/bridge/lib/prismarine-chunk/src/pc/common/PaletteBiome.js new file mode 100644 index 0000000..d38896e --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/PaletteBiome.js @@ -0,0 +1,110 @@ +const constants = require('./constants') +const paletteContainer = require('./PaletteContainer') +const varInt = require('../common/varInt') +const SingleValueContainer = paletteContainer.SingleValueContainer +const IndirectPaletteContainer = paletteContainer.IndirectPaletteContainer +const DirectPaletteContainer = paletteContainer.DirectPaletteContainer + +function getBiomeIndex (pos) { + return (pos.y << 4) | (pos.z << 2) | pos.x +} + +class BiomeSection { + constructor (options) { + this.noSizePrefix = options?.noSizePrefix // 1.21.5+ writes no size prefix before chunk containers, it's computed dynamically to save 1 byte + this.data = options?.data ?? new SingleValueContainer({ + noSizePrefix: this.noSizePrefix, + value: options?.singleValue ?? 0, + bitsPerValue: constants.MIN_BITS_PER_BIOME, + capacity: constants.BIOME_SECTION_VOLUME, + maxBits: constants.MAX_BITS_PER_BIOME + }) + } + + toJson () { + return this.data.toJson() + } + + static fromJson (j) { + return new BiomeSection({ + data: paletteContainer.fromJson(j) + }) + } + + get (pos) { + return this.data.get(getBiomeIndex(pos)) + } + + set (pos, biomeId) { + this.data = this.data.set(getBiomeIndex(pos), biomeId) + } + + static fromLocalPalette ({ palette, data, noSizePrefix }) { + return new BiomeSection({ + noSizePrefix, + data: palette.length === 1 + ? new SingleValueContainer({ + noSizePrefix, + value: palette[0], + bitsPerValue: constants.MIN_BITS_PER_BIOME, + capacity: constants.BIOME_SECTION_VOLUME, + maxBits: constants.MAX_BITS_PER_BIOME + }) + : new IndirectPaletteContainer({ + noSizePrefix, + palette, + data + }) + }) + } + + write (smartBuffer) { + this.data.write(smartBuffer) + } + + static read (smartBuffer, maxBitsPerBiome = constants.GLOBAL_BITS_PER_BIOME, noSizePrefix) { + const bitsPerBiome = smartBuffer.readUInt8() + if (bitsPerBiome > 8) throw new Error(`Bits per biome is too big: ${bitsPerBiome}`) + + // Case 1: Single Value Container (all biomes in the section are the same) + if (bitsPerBiome === 0) { + const section = new BiomeSection({ + noSizePrefix, + singleValue: varInt.read(smartBuffer) + }) + if (!noSizePrefix) smartBuffer.readUInt8() + return section + } + + // Case 2: Direct Palette (global palette) + if (bitsPerBiome > constants.MAX_BITS_PER_BIOME) { + return new BiomeSection({ + noSizePrefix, + data: new DirectPaletteContainer({ + noSizePrefix, + bitsPerValue: maxBitsPerBiome, + capacity: constants.BIOME_SECTION_VOLUME + }).readBuffer(smartBuffer, bitsPerBiome) + }) + } + + // Case 3: Indirect Palette (local palette) + const palette = [] + const paletteLength = varInt.read(smartBuffer) + for (let i = 0; i < paletteLength; ++i) { + palette.push(varInt.read(smartBuffer)) + } + + return new BiomeSection({ + data: new IndirectPaletteContainer({ + noSizePrefix, + bitsPerValue: bitsPerBiome, + capacity: constants.BIOME_SECTION_VOLUME, + maxBits: constants.MAX_BITS_PER_BIOME, + palette + }).readBuffer(smartBuffer, bitsPerBiome) + }) + } +} + +module.exports = BiomeSection diff --git a/bridge/lib/prismarine-chunk/src/pc/common/PaletteChunkSection.js b/bridge/lib/prismarine-chunk/src/pc/common/PaletteChunkSection.js new file mode 100644 index 0000000..b2b2835 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/PaletteChunkSection.js @@ -0,0 +1,150 @@ +const constants = require('./constants') +const paletteContainer = require('./PaletteContainer') +const varInt = require('../common/varInt') +const SingleValueContainer = paletteContainer.SingleValueContainer +const IndirectPaletteContainer = paletteContainer.IndirectPaletteContainer +const DirectPaletteContainer = paletteContainer.DirectPaletteContainer + +function getBlockIndex (pos) { + return (pos.y << 8) | (pos.z << 4) | pos.x +} + +class ChunkSection { + constructor (options) { + this.noSizePrefix = options?.noSizePrefix // 1.21.5+ writes no size prefix before chunk containers, it's computed dynamically to save 1 byte + this.data = options?.data + if (!this.data) { + const value = options?.singleValue ?? 0 + this.data = new SingleValueContainer({ + value, + bitsPerValue: constants.MIN_BITS_PER_BLOCK, + capacity: constants.BLOCK_SECTION_VOLUME, + maxBits: constants.MAX_BITS_PER_BLOCK, + maxBitsPerBlock: options?.maxBitsPerBlock ?? constants.GLOBAL_BITS_PER_BLOCK, + noSizePrefix: this.noSizePrefix + }) + this.solidBlockCount = value ? constants.BLOCK_SECTION_VOLUME : 0 + } else { + this.solidBlockCount = options?.solidBlockCount ?? 0 + if (options?.solidBlockCount == null) { + for (let i = 0; i < constants.BLOCK_SECTION_VOLUME; ++i) { + if (this.data.get(i)) { this.solidBlockCount++ } + } + } + } + this.palette = this.data.palette + } + + toJson () { + return JSON.stringify({ + data: this.data.toJson(), + solidBlockCount: this.solidBlockCount + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + return new ChunkSection({ + data: paletteContainer.fromJson(parsed.data), + solidBlockCount: parsed.solidBlockCount + }) + } + + get (pos) { + return this.data.get(getBlockIndex(pos)) + } + + set (pos, stateId) { + const blockIndex = getBlockIndex(pos) + + const oldBlock = this.get(pos) + if (stateId === 0 && oldBlock !== 0) { + this.solidBlockCount -= 1 + } else if (stateId !== 0 && oldBlock === 0) { + this.solidBlockCount += 1 + } + + this.data = this.data.set(blockIndex, stateId) + this.palette = this.data.palette + } + + isEmpty () { + return this.solidBlockCount === 0 + } + + write (smartBuffer) { + smartBuffer.writeInt16BE(this.solidBlockCount) + this.data.write(smartBuffer) + } + + static fromLocalPalette ({ data, palette, noSizePrefix }) { + return new ChunkSection({ + noSizePrefix, + data: palette.length === 1 + ? new SingleValueContainer({ + noSizePrefix, + value: palette[0], + bitsPerValue: constants.MIN_BITS_PER_BLOCK, + capacity: constants.BIOME_SECTION_VOLUME, + maxBits: constants.MAX_BITS_PER_BLOCK + }) + : new IndirectPaletteContainer({ + noSizePrefix, + data, + palette + }) + }) + } + + static read (smartBuffer, maxBitsPerBlock = constants.GLOBAL_BITS_PER_BLOCK, noSizePrefix) { + const solidBlockCount = smartBuffer.readInt16BE() + const bitsPerBlock = smartBuffer.readUInt8() + if (bitsPerBlock > 16) throw new Error(`Bits per block is too big: ${bitsPerBlock}`) + // Case 1: Single Value Container (all blocks in the section are the same) + if (bitsPerBlock === 0) { + const section = new ChunkSection({ + noSizePrefix, + solidBlockCount, + singleValue: varInt.read(smartBuffer), + maxBitsPerBlock + }) + if (!noSizePrefix) smartBuffer.readUInt8() + return section + } + + // Case 2: Direct Palette (global palette) + if (bitsPerBlock > constants.MAX_BITS_PER_BLOCK) { + return new ChunkSection({ + noSizePrefix, + solidBlockCount, + data: new DirectPaletteContainer({ + noSizePrefix, + bitsPerValue: maxBitsPerBlock, + capacity: constants.BLOCK_SECTION_VOLUME + }).readBuffer(smartBuffer, bitsPerBlock) + }) + } + + // Case 3: Indirect Palette (local palette) + const palette = [] + const paletteLength = varInt.read(smartBuffer) + for (let i = 0; i < paletteLength; ++i) { + palette.push(varInt.read(smartBuffer)) + } + + return new ChunkSection({ + noSizePrefix, + solidBlockCount, + data: new IndirectPaletteContainer({ + noSizePrefix, + bitsPerValue: bitsPerBlock, + capacity: constants.BLOCK_SECTION_VOLUME, + maxBits: constants.MAX_BITS_PER_BLOCK, + maxBitsPerBlock, + palette + }).readBuffer(smartBuffer, bitsPerBlock) + }) + } +} + +module.exports = ChunkSection diff --git a/bridge/lib/prismarine-chunk/src/pc/common/PaletteContainer.js b/bridge/lib/prismarine-chunk/src/pc/common/PaletteContainer.js new file mode 100644 index 0000000..131e08c --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/PaletteContainer.js @@ -0,0 +1,239 @@ +const BitArray = require('./BitArrayNoSpan') +const constants = require('./constants') +const neededBits = require('./neededBits') +const varInt = require('./varInt') + +class DirectPaletteContainer { + constructor (options) { + this.noSizePrefix = options?.noSizePrefix // 1.21.5+ writes no size prefix before chunk containers, it's computed dynamically to save 1 byte + this.data = new BitArray({ + bitsPerValue: options?.bitsPerValue ?? constants.GLOBAL_BITS_PER_BLOCK, + capacity: options?.capacity ?? constants.BLOCK_SECTION_VOLUME + }) + } + + get (index) { + return this.data.get(index) + } + + set (index, value) { + this.data.set(index, value) + return this + } + + write (smartBuffer) { + smartBuffer.writeUInt8(this.data.bitsPerValue) + if (!this.noSizePrefix) varInt.write(smartBuffer, this.data.length()) + this.data.writeBuffer(smartBuffer) + } + + readBuffer (smartBuffer, bitsPerValue) { + const longs = this.noSizePrefix + ? Math.ceil(this.data.capacity / Math.floor(64 / bitsPerValue)) + : varInt.read(smartBuffer) + this.data.readBuffer(smartBuffer, longs * 2) + return this + } + + toJson () { + return JSON.stringify({ + type: 'direct', + data: this.data.toJson() + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + return new DirectPaletteContainer({ + noSizePrefix: parsed.noSizePrefix, + data: BitArray.fromJson(parsed.data), + bitsPerValue: parsed.bitsPerValue + }) + } +} + +class IndirectPaletteContainer { + constructor (options) { + this.noSizePrefix = options?.noSizePrefix + this.data = options?.data ?? new BitArray({ + bitsPerValue: options?.bitsPerValue ?? constants.MIN_BITS_PER_BLOCK, + capacity: options?.capacity ?? constants.BLOCK_SECTION_VOLUME + }) + + this.palette = options?.palette ?? [0] + this.maxBits = options?.maxBits ?? constants.MAX_BITS_PER_BLOCK + this.maxBitsPerBlock = options?.maxBitsPerBlock ?? constants.MAX_BITS_PER_BLOCK + } + + get (index) { + return this.palette[this.data.get(index)] + } + + set (index, value) { + let paletteIndex = this.palette.indexOf(value) + if (paletteIndex < 0) { + paletteIndex = this.palette.length + this.palette.push(value) + const bitsPerValue = neededBits(paletteIndex) + if (bitsPerValue > this.data.bitsPerValue) { + if (bitsPerValue <= this.maxBits) { + this.data = this.data.resizeTo(bitsPerValue) + } else { + return this.convertToDirect(this.maxBitsPerBlock).set(index, value) + } + } + } + this.data.set(index, paletteIndex) + return this + } + + convertToDirect (bitsPerValue) { + const direct = new DirectPaletteContainer({ + noSizePrefix: this.noSizePrefix, + bitsPerValue: bitsPerValue ?? constants.GLOBAL_BITS_PER_BLOCK, + capacity: this.data.capacity + }) + for (let i = 0; i < this.data.capacity; ++i) { + direct.data.set(i, this.get(i)) + } + return direct + } + + write (smartBuffer) { + smartBuffer.writeUInt8(this.data.bitsPerValue) + varInt.write(smartBuffer, this.palette.length) + for (const paletteElement of this.palette) { + varInt.write(smartBuffer, paletteElement) + } + if (!this.noSizePrefix) varInt.write(smartBuffer, this.data.length()) + this.data.writeBuffer(smartBuffer) + } + + readBuffer (smartBuffer, bitsPerValue) { + const longs = this.noSizePrefix + ? Math.ceil(this.data.capacity / Math.floor(64 / bitsPerValue)) + : varInt.read(smartBuffer) + this.data.readBuffer(smartBuffer, longs * 2) + return this + } + + toJson () { + return JSON.stringify({ + type: 'indirect', + palette: this.palette, + maxBits: this.maxBits, + maxBitsPerBlock: this.maxBitsPerBlock, + data: this.data.toJson() + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + return new IndirectPaletteContainer({ + noSizePrefix: parsed.noSizePrefix, + palette: parsed.palette, + maxBits: parsed.maxBits, + maxBitsPerBlock: parsed.maxBitsPerBlock, + data: BitArray.fromJson(parsed.data) + }) + } +} + +class SingleValueContainer { + constructor (options) { + this.noSizePrefix = options?.noSizePrefix + this.value = options?.value ?? 0 + this.bitsPerValue = options?.bitsPerValue ?? constants.MIN_BITS_PER_BLOCK + this.capacity = options?.capacity ?? constants.BLOCK_SECTION_VOLUME + this.maxBits = options?.maxBits ?? constants.MAX_BITS_PER_BLOCK + this.maxBitsPerBlock = options?.maxBitsPerBlock ?? constants.MAX_BITS_PER_BLOCK + } + + get (index) { + return this.value + } + + set (index, value) { + if (value === this.value) { return this } + + const data = new BitArray({ + bitsPerValue: this.bitsPerValue, + capacity: this.capacity + }) + data.set(index, 1) + + return new IndirectPaletteContainer({ + noSizePrefix: this.noSizePrefix, + data, + palette: [this.value, value], + capacity: this.capacity, + bitsPerValue: this.bitsPerValue, + maxBits: this.maxBits, + maxBitsPerBlock: this.maxBitsPerBlock + }) + } + + write (smartBuffer) { + smartBuffer.writeUInt8(0) // bitsPerValue is 0 for single value + varInt.write(smartBuffer, this.value) + if (!this.noSizePrefix) smartBuffer.writeUInt8(0) + } + + toJson () { + return JSON.stringify({ + type: 'single', + value: this.value, + bitsPerValue: this.bitsPerValue, + capacity: this.capacity, + maxBits: this.maxBits, + maxBitsPerBlock: this.maxBitsPerBlock + }) + } + + static fromJson (j) { + const parsed = JSON.parse(j) + return new SingleValueContainer({ + noSizePrefix: parsed.noSizePrefix, + value: parsed.value, + bitsPerValue: parsed.bitsPerValue, + capacity: parsed.capacity, + maxBits: parsed.maxBits, + maxBitsPerBlock: parsed.maxBitsPerBlock + }) + } +} + +function containerFromJson (j) { + const parsed = JSON.parse(j) + if (parsed.type === 'direct') { + return new DirectPaletteContainer({ + noSizePrefix: parsed.noSizePrefix, + data: BitArray.fromJson(parsed.data) + }) + } else if (parsed.type === 'indirect') { + return new IndirectPaletteContainer({ + noSizePrefix: parsed.noSizePrefix, + palette: parsed.palette, + maxBits: parsed.maxBits, + data: BitArray.fromJson(parsed.data), + maxBitsPerBlock: parsed.maxBitsPerBlock + }) + } else if (parsed.type === 'single') { + return new SingleValueContainer({ + noSizePrefix: parsed.noSizePrefix, + value: parsed.value, + bitsPerValue: parsed.bitsPerValue, + capacity: parsed.capacity, + maxBits: parsed.maxBits, + maxBitsPerBlock: parsed.maxBitsPerBlock + }) + } + return undefined +} + +module.exports = { + SingleValueContainer, + IndirectPaletteContainer, + DirectPaletteContainer, + fromJson: containerFromJson +} diff --git a/bridge/lib/prismarine-chunk/src/pc/common/constants.js b/bridge/lib/prismarine-chunk/src/pc/common/constants.js new file mode 100644 index 0000000..0ed4331 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/constants.js @@ -0,0 +1,52 @@ +// height in blocks of a chunk column +const CHUNK_HEIGHT = 256 + +// width in blocks of a chunk column +const CHUNK_WIDTH = 16 + +// height in blocks of a chunk section +const SECTION_HEIGHT = 16 + +// width in blocks of a chunk section +const SECTION_WIDTH = 16 + +// volume in blocks of a chunk section +const BLOCK_SECTION_VOLUME = SECTION_HEIGHT * SECTION_WIDTH * SECTION_WIDTH + +// number of chunk sections in a chunk column +const NUM_SECTIONS = 16 + +// minimum number of bits per block allowed when using the section palette. +const MIN_BITS_PER_BLOCK = 4 + +// maximum number of bits per block allowed when using the section palette. +// values above will switch to global palette +const MAX_BITS_PER_BLOCK = 8 + +// number of bits used for each block in the global palette. +// this value should not be hardcoded according to wiki.vg +const GLOBAL_BITS_PER_BLOCK = 16 + +const BIOME_SECTION_VOLUME = BLOCK_SECTION_VOLUME / (4 * 4 * 4) | 0 + +const MIN_BITS_PER_BIOME = 1 + +const MAX_BITS_PER_BIOME = 3 + +const GLOBAL_BITS_PER_BIOME = 6 + +module.exports = { + CHUNK_HEIGHT, + CHUNK_WIDTH, + SECTION_HEIGHT, + SECTION_WIDTH, + BLOCK_SECTION_VOLUME, + NUM_SECTIONS, + MIN_BITS_PER_BLOCK, + MAX_BITS_PER_BLOCK, + GLOBAL_BITS_PER_BLOCK, + BIOME_SECTION_VOLUME, + MIN_BITS_PER_BIOME, + MAX_BITS_PER_BIOME, + GLOBAL_BITS_PER_BIOME +} diff --git a/bridge/lib/prismarine-chunk/src/pc/common/neededBits.js b/bridge/lib/prismarine-chunk/src/pc/common/neededBits.js new file mode 100644 index 0000000..697c89e --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/neededBits.js @@ -0,0 +1,10 @@ +/** + * Gives the number of bits needed to represent the value + * @param {number} value + * @returns {number} bits + */ +function neededBits (value) { + return 32 - Math.clz32(value) +} + +module.exports = neededBits diff --git a/bridge/lib/prismarine-chunk/src/pc/common/varInt.js b/bridge/lib/prismarine-chunk/src/pc/common/varInt.js new file mode 100644 index 0000000..6742959 --- /dev/null +++ b/bridge/lib/prismarine-chunk/src/pc/common/varInt.js @@ -0,0 +1,45 @@ +/** + * Writes `value` into `buffer` + * https://wiki.vg/Data_types#VarInt_and_VarLong + * + * @param {SmartBuffer} buffer + * @param {*} value + */ +exports.write = (buffer, value) => { + do { + // would want to use the numeric separator but standardjs doesn't allow it + let temp = value & 0b01111111 + value >>>= 7 + if (value !== 0) { + // would want to use the numeric separator but standardjs doesn't allow it + temp |= 0b10000000 + } + buffer.writeUInt8(temp) + } while (value !== 0) +} + +/** + * Reads into `buffer` + * https://wiki.vg/Data_types#VarInt_and_VarLong + * + * @param {SmartBuffer} buffer + */ +exports.read = buffer => { + let numRead = 0 + let result = 0 + let read + do { + read = buffer.readUInt8() + // would want to use the numeric separator but standardjs doesn't allow it + const value = read & 0b01111111 + result |= value << (7 * numRead) + + numRead++ + if (numRead > 5) { + throw new Error('varint is too big') + } + // would want to use the numeric separator but standardjs doesn't allow it + } while ((read & 0b10000000) !== 0) + + return result +} diff --git a/bridge/lib/prismarine-chunk/tools/generate-bedrock-test-data.mjs b/bridge/lib/prismarine-chunk/tools/generate-bedrock-test-data.mjs new file mode 100644 index 0000000..d6c12f1 --- /dev/null +++ b/bridge/lib/prismarine-chunk/tools/generate-bedrock-test-data.mjs @@ -0,0 +1,426 @@ +import { createServer } from 'net' +import { startServerAndWait2 } from 'minecraft-bedrock-server' +import { createClient } from 'bedrock-protocol' +import PrismarineRegistry from 'prismarine-registry' +import PrismarineChunk, { BlobEntry, BlobType } from 'prismarine-chunk' +import assert from 'assert' +import path, { dirname } from 'path' +import { mkdirSync, writeFileSync } from 'fs' +import { fileURLToPath } from 'url' +const __dirname = dirname(fileURLToPath(import.meta.url)) + +class BlobStore extends Map { + pending = {} + wanted = [] + + set (key, value) { + const ret = super.set(key.toString(), value) + this.wanted.forEach(wanted => { + wanted[0] = wanted[0].filter(hash => hash.toString() !== key.toString()) + }) + for (const i in this.wanted) { + const [outstandingBlobs, cb] = this.wanted[i] + if (!outstandingBlobs.length) { + cb() + delete this.wanted[i] + } + } + return ret + } + + get (key) { + return super.get(key.toString()) + } + + has (key) { + return super.has(key.toString()) + } + + addPending (hash, blob) { + this.pending[hash.toString()] = blob + } + + updatePending (hash, value) { + const name = hash.toString() + if (this.pending[name]) { + this.set(name, Object.assign(this.pending[name], value)) + } else { + throw new Error('No pending blob for hash ' + name) + } + } + + once (wantedBlobs, cb) { + const outstanding = [] + for (const wanted of wantedBlobs) { + if (!this.has(wanted)) outstanding.push(wanted) + } + + if (outstanding.length) { + this.wanted.push([outstanding, cb]) + } else { + cb() + } + } +} + +if (process.argv.length !== 6) { + console.error('Usage: node tools/generate-bedrock-test-data.mjs ') + console.error('Example: node tools/generate-bedrock-test-data.mjs 1.21.60 -7 10 8403237569561413924') + process.exit(1) +} + +const [, , version, chunkXStr, chunkZStr, levelSeed] = process.argv + +if (!/^\d+\.\d+\.\d+$/.test(version)) { + console.error('Invalid version format. Must be x.y.z (e.g., 1.21.60)') + process.exit(1) +} + +const chunkX = parseInt(chunkXStr) +const chunkZ = parseInt(chunkZStr) +if (isNaN(chunkX) || isNaN(chunkZ)) { + console.error('Chunk coordinates must be integers') + process.exit(1) +} + +await main(version, chunkX, chunkZ, levelSeed) + +async function main (version, chunkX, chunkZ, levelSeed) { + const registry = PrismarineRegistry('bedrock_' + version) + + for (const blockNetworkIdsAreHashes of registry.supportFeature('blockHashes') ? [false, true] : [false]) { + for (const cachingEnabled of [false, true]) { + await generateTestData(version, chunkX, chunkZ, cachingEnabled, blockNetworkIdsAreHashes, registry, levelSeed) + } + } +} + +async function generateTestData (version, chunkX, chunkZ, cachingEnabled, blockNetworkIdsAreHashes, registry, levelSeed) { + const random = (Math.random() * 1000) | 0 + const [port, v6] = [await getPort(), await getPort()] + + const ChunkColumn = PrismarineChunk(registry) + const blobStore = new BlobStore() + + const bedrockServers = path.resolve(__dirname, '..', 'tools', 'bedrock_servers', version) + mkdirSync(bedrockServers, { recursive: true }) + + const handle = await startServerAndWait2(version, 1000 * 120, { + 'server-port': port, + 'server-portv6': v6, + 'level-seed': levelSeed, + 'block-network-ids-are-hashes': blockNetworkIdsAreHashes, + path: bedrockServers + }) + const ccs = {} + let subChunkMissHashes = [] + + const client = createClient({ + host: '127.0.0.1', + port, + version, + username: 'Packet' + random, + offline: true + }) + + await waitFor(120_000, () => saveChunkData()).finally(() => { + client.close() + handle.kill() + }) + + async function saveChunkData () { + let timeoutId + + client.on('start_game', (params) => { + registry.handleStartGame({ ...params, itemstates: [] }) + }) + client.on('join', () => { + resetTimeout() + client.queue('client_cache_status', { enabled: cachingEnabled }) + }) + + client.on('level_chunk', (params) => { + resetTimeout() + saveLevelChunk(params) + processLevelChunk(params) + }) + + client.on('subchunk', (params) => { + resetTimeout() + saveSubChunk(params) + processSubChunk(params) + }) + client.on('client_cache_miss_response', (params) => { + processCacheMiss(params) + }) + + function resetTimeout () { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => { + handle.kill() + client.close() + done() + }, 5000) + } + + let done + const donePromise = new Promise((resolve) => { + done = resolve + }) + return donePromise + } + + async function processLevelChunk (packet) { + const cc = new ChunkColumn({ x: packet.x, z: packet.z }) + if (!cachingEnabled) { + await cc.networkDecodeNoCache(packet.payload, packet.sub_chunk_count) + } else if (cachingEnabled) { + const misses = await cc.networkDecode(packet.blobs.hashes, blobStore, packet.payload) + if (!packet.blobs.hashes.length) { return } + + client.queue('client_cache_blob_status', { + misses: misses.length, + haves: 0, + have: [], + missing: misses + }) + + if (packet.sub_chunk_count < 0) { + for (const miss of misses) { + blobStore.addPending(miss, new BlobEntry({ type: BlobType.Biomes, x: packet.x, z: packet.z })) + } + } else { + const lastBlob = packet.blobs.hashes[packet.blobs.hashes.length - 1] + for (const miss of misses) { + blobStore.addPending(miss, new BlobEntry({ + type: miss === lastBlob + ? BlobType.Biomes + : BlobType.ChunkSection, + x: packet.x, + z: packet.z + })) + } + } + + blobStore.once(misses, async () => { + const now = await cc.networkDecode(packet.blobs.hashes, blobStore, packet.payload) + + if (packet.x === chunkX && packet.z === chunkZ) { + saveLevelChunkCacheMiss(packet, version, cachingEnabled, blockNetworkIdsAreHashes) + } + + assert.strictEqual(now.length, 0) + client.queue('client_cache_blob_status', { + misses: 0, + haves: packet.blobs.hashes.length, + have: packet.blobs.hashes, + missing: [] + }) + }) + } + + if (packet.sub_chunk_count < 0) { + const maxSubChunkCount = packet.highest_subchunk_count || 5 // field is set if sub_chunk_count=-2 (1.18.10+) + + if (registry.version['>=']('1.18.11')) { + const requests = [] + for (let i = 0; i <= maxSubChunkCount; i++) { + requests.push({ dx: 0, dz: 0, dy: cc.minCY + i }) + } + client.queue('subchunk_request', { origin: { x: packet.x, z: packet.z, y: 0 }, requests, dimension: 0 }) + } else if (registry.version['>=']('1.18')) { + for (let i = cc.minCY; i < maxSubChunkCount; i++) { + client.queue('subchunk_request', { x: packet.x, z: packet.z, y: i, dimension: 0 }) + } + } + } + + ccs[packet.x + ',' + packet.z] = cc + } + + function getTestCaseName (cachingEnabled, blockNetworkIdsAreHashes) { + let description = '' + + if (cachingEnabled) { + description = 'cache' + } else { + description = 'no-cache' + } + + if (blockNetworkIdsAreHashes) { + description += ' hash' + } else { + description += ' no-hash' + } + + return description + } + + async function processSubChunk (packet) { + if (packet.entries) { // 1.18.10+ handling + for (const entry of packet.entries) { + const x = packet.origin.x + entry.dx + const y = packet.origin.y + entry.dy + const z = packet.origin.z + entry.dz + const cc = ccs[x + ',' + z] + if (entry.result === 'success') { + if (packet.cache_enabled) { + await loadCached(cc, x, y, z, entry.blob_id, entry.payload) + } else { + await cc.networkDecodeSubChunkNoCache(y, entry.payload) + } + } + } + } else { + if (packet.request_result !== 'success') { + return + } + const cc = ccs[packet.x + ',' + packet.z] + if (packet.cache_enabled) { + await loadCached(cc, packet.x, packet.y, packet.z, packet.blob_id, packet.data) + } else { + await cc.networkDecodeSubChunkNoCache(packet.y, packet.data) + } + } + } + + async function loadCached (cc, x, y, z, blobId, extraData) { + const misses = await cc.networkDecodeSubChunk([blobId], blobStore, extraData) + subChunkMissHashes.push(...misses) + + for (const miss of misses) { + blobStore.addPending(miss, new BlobEntry({ type: BlobType.ChunkSection, x, z, y })) + } + + if (subChunkMissHashes.length >= 10) { + const r = { + misses: subChunkMissHashes.length, + haves: 0, + have: [], + missing: subChunkMissHashes + } + + client.queue('client_cache_blob_status', r) + subChunkMissHashes = [] + } + + if (misses.length) { + const [missed] = misses + // Once we get this blob, try again + blobStore.once([missed], async () => { + saveSubchunkCacheMiss(missed, x, y, z) + // Call this again, ignore the payload since that's already been decoded + const misses = await cc.networkDecodeSubChunk([missed], blobStore) + assert(!misses.length, 'Should not have missed anything') + + const [hash] = await cc.networkEncodeSubChunk(y, blobStore) + assert(hash.toString() === missed.toString(), 'Should not have missed anything') + }) + } + } + + async function processCacheMiss (packet) { + const acks = [] + for (const { hash, payload } of packet.blobs) { + const name = hash.toString() + blobStore.updatePending(name, { buffer: payload }) + acks.push(hash) + } + + // Send back an ACK + client.queue('client_cache_blob_status', { + misses: 0, + haves: acks.length, + have: [], + missing: acks + }) + } + + function serialize (obj) { + return JSON.stringify(obj, (k, v) => typeof v?.valueOf?.() === 'bigint' ? v.toString() : v) + } + + function saveSubchunkCacheMiss (missed, x, y, z) { + if (x !== chunkX || z !== chunkZ) { + return + } + const data = { blobs: Object.fromEntries([[missed.toString(), blobStore.get(missed).buffer]]) } + const directory = path.resolve(__dirname, '..', 'test', `bedrock_${version}`, getTestCaseName(cachingEnabled, blockNetworkIdsAreHashes)) + const filename = `subchunk CacheMissResponse ${x},${z},${y}.json`.replace(/\s\s+/g, ' ') + mkdirSync(directory, { recursive: true }) + writeFileSync(path.resolve(directory, filename), serialize(data)) + } + + function saveLevelChunkCacheMiss (packet, version, cachingEnabled, blockNetworkIdsAreHashes) { + if (packet.x !== chunkX || packet.z !== chunkZ) { + return + } + const data = { blobs: Object.fromEntries(packet.blobs.hashes.map(h => [h.toString(), blobStore.get(h).buffer])) } + const directory = path.resolve(__dirname, '..', 'test', `bedrock_${version}`, getTestCaseName(cachingEnabled, blockNetworkIdsAreHashes)) + const filename = `level_chunk CacheMissResponse ${packet.x},${packet.z}.json`.replace(/\s\s+/g, ' ') + mkdirSync(directory, { recursive: true }) + writeFileSync(path.resolve(directory, filename), serialize(data, 1)) + } + + function saveLevelChunk (params) { + if (params.x !== chunkX || params.z !== chunkZ) { + return + } + + const data = params + const directory = path.resolve(__dirname, '..', 'test', `bedrock_${version}`, getTestCaseName(cachingEnabled, blockNetworkIdsAreHashes)) + const filename = `level_chunk ${params.x},${params.z}.json`.replace(/\s\s+/g, ' ') + mkdirSync(directory, { recursive: true }) + writeFileSync(path.resolve(directory, filename), serialize(data)) + } + + function saveSubChunk (params) { + if (params.origin) { + if (params.origin.x !== chunkX || params.origin.z !== chunkZ) { + return + } + const data = params + const directory = path.resolve(__dirname, '..', 'test', `bedrock_${version}`, getTestCaseName(cachingEnabled, blockNetworkIdsAreHashes)) + const filename = `subchunk ${params.origin.x},${params.origin.z},${params.origin.y}.json`.replace(/\s\s+/g, ' ') + mkdirSync(directory, { recursive: true }) + writeFileSync(path.resolve(directory, filename), serialize(data)) + } else { + if (params.x !== chunkX || params.z !== chunkZ) { + return + } + const data = params + const directory = path.resolve(__dirname, '..', 'test', `bedrock_${version}`, getTestCaseName(cachingEnabled, blockNetworkIdsAreHashes)) + const filename = `subchunk ${params.x},${params.z},${params.y}.json`.replace(/\s\s+/g, ' ') + mkdirSync(directory, { recursive: true }) + writeFileSync(path.resolve(directory, filename), serialize(data)) + } + } + + function getPort () { + return new Promise(resolve => { + const server = createServer() + server.listen(0, '127.0.0.1') + server.on('listening', () => { + const { port } = server.address() + server.close(() => { + // Wait a bit for port to free as we try to bind right after freeing it + setTimeout(() => { resolve(port) }, 200) + }) + }) + }) + } + + async function waitFor (withTimeout, cb) { + let t + const ret = await Promise.race([ + cb(), + new Promise((resolve, reject) => { + t = setTimeout(() => reject(new Error('timeout')), withTimeout) + }) + ]).then(() => { + clearTimeout(t) + }) + + return ret + } +} diff --git a/bridge/lib/prismarine-chunk/types/index.d.ts b/bridge/lib/prismarine-chunk/types/index.d.ts new file mode 100644 index 0000000..b966d07 --- /dev/null +++ b/bridge/lib/prismarine-chunk/types/index.d.ts @@ -0,0 +1,228 @@ +import { Biome } from "prismarine-biome" +import { Block } from "prismarine-block" +import { Vec3 } from "vec3" +import { NBT } from "prismarine-nbt" +import { Registry } from 'prismarine-registry' +import Section from "./section" + +declare class CommonChunk { + static fromJson(j: any): typeof this + toJson(): string + + initialize(iniFunc: (x: number, y: number, z: number) => Block): void + + /** @deprecated This function only works on MCPE v0.14 */ + setBiomeColor(pos: Vec3, r: number, g: number, b: number): void +} + +declare class PCChunk extends CommonChunk { + constructor(initData: { + // Only present on 1.18+ + minY?: number, + worldHeight?: number + } | null) + + skyLightSent: boolean + sections: Section[] + biome: Buffer + + getBlock(pos: Vec3): Block + setBlock(pos: Vec3, block: Block): void + + getBlockStateId(pos: Vec3): number + getBlockType(pos: Vec3): number + getBlockData(pos: Vec3): number + getBlockLight(pos: Vec3): number + getSkyLight(pos: Vec3): number + getBiome(pos: Vec3): number + setBlockStateId(pos: Vec3, stateId: number): number + setBlockType(pos: Vec3, id: number): void + setBlockData(pos: Vec3, data: Buffer): void + setBlockLight(pos: Vec3, light: number): void + setSkyLight(pos: Vec3, light: number): void + setBiome(pos: Vec3, biome: number): void + + getBiomeColor(pos: Vec3): { r: number; g: number; b: number; } + dumpBiomes(): Array + dumpLight(): Buffer + loadLight(data: Buffer, skyLightMask: number, blockLightMask: number, emptySkyLightMask?: number, emptyBlockLightMask?: number): void + loadParsedLight?(skyLight: Buffer[], blockLight: Buffer[], skyLightMask: number[][], blockLightMask: number[][], emptySkyLightMask: number[][], emptyBlockLightMask: number[][]): void + loadBiomes(newBiomesArray: Array): void; + dump(bitMap?: number, skyLightSent?: boolean): Buffer + load(data: Buffer, bitMap?: number, skyLightSent?: boolean, fullChunk?: boolean): void + getMask(): number + + getSection(pos: Vec3): Section + // Returns chunk at a Y index, adjusted for chunks at negative-Y + getSectionAtIndex(chunkY: number): SubChunk +} + +//// Bedrock //// + +interface IVec4 { + x: number + y: number + z: number + l?: number +} + +// This manages the chunk cache +interface IBlobStore { + get(key: string | number | BigInt): object + set(key: string | number | BigInt, value: object): void + has(key: string | number | BigInt): boolean +} + +declare const enum StorageType { + LocalPersistence, + NetworkPersistence, + Runtime +} + +type CCHash = { type: BlobType, hash: BigInt } +type PaletteEntry = { name, stateId, states } + +declare class SubChunk { + encode(storageType: StorageType): Promise + decode(storageType: StorageType, streamBuffer: Buffer): void + + // Returns an array of currently stored blocks in this section + getPalette(): PaletteEntry[] + + // Whether this section can be compacted (reduced in size) + isCompactable(): boolean + // Reduces the size of this section + compact(): void +} + +type ExtendedBlock = Block & { + light?: number + skyLight?: number +} + +// A stub +declare class Stream { +} + +declare class BedrockChunk extends CommonChunk { + x: number + z: number + // World height information + minCY: number + maxCY: number + // The version of the chunk column (analog to DataVersion on PCChunk) + chunkVersion: number + // Holds all the block entities in the chunk, the string keys are + // the concatenated chunk column-relative position of the block. + blockEntities: Record + // Holds entities in the chunk, the string key is the entity ID + entities: Record + + constructor(options: { x: number, z: number, chunkVersion?: number }) + + // Block management + getBlock(pos: IVec4, full?: boolean): ExtendedBlock + setBlock(pos: IVec4, block: ExtendedBlock): void + + setBlockStateId(pos: IVec4, stateId: number): number + getBlockStateId(pos: IVec4): number + + // Returns list of unique blocks in this chunk column + getBlocks(): PaletteEntry[] + + // Biomes + getBiome(pos: Vec3): Biome + setBiome(pos: Vec3, biome: Biome): void + getBiomeId(pos: Vec3): number + setBiomeId(pos: Vec3, biomeId: number): void + loadLegacyBiomes(buffer: Buffer): void + // Only present on >= 1.18 + loadBiomes(buffer: Buffer | Stream, storageType: StorageType): void + // Write 2D biome data to stream + writeLegacyBiomes(stream): void + // Write 3D biome data to stream + writeBiomes(stream): void + + // Lighting + getBlockLight(pos: Vec3): number + setBlockLight(pos: Vec3, light: number): void + getSkyLight(pos: Vec3): number + setSkyLight(pos: Vec3, light: number): void + + // On versions <1.18: Encode this full chunk column without computing a checksum at the end + // On version >=1.18: Encode the biome data for this chunk column and border blocks + networkEncodeNoCache(): Promise + // Compute checksums and put into blob store. Returns blob hashes maped to the blob store. + networkEncode(blobStore: IBlobStore): Promise<{ blobs: CCHash[] }> + + // On versions <=1.18: Decode this full chunk column without computing a checksum at the end + // On version >=1.18: Decode the biome data for this chunk column and border blocks + networkDecodeNoCache(buffer: Buffer, sectionCount: number): Promise + /** + * Decodes cached chunks sent over the network + * @param blobs The blob hashes sent in the Chunk packet + * @param blobStore Our blob store for cached data + * @param {Buffer} payload The rest of the non-cached data + * @returns {CCHash[]} A list of hashes we don't have and need. If len > 0, decode failed. + */ + networkDecode(blobs: BigInt[], blobStore: IBlobStore, payload: Buffer): Promise + + + // On version >=1.18: Encode/Decode block and entity NBT data for this chunk column + networkDecodeSubChunkNoCache(y: number, buffer: Buffer): Promise + networkEncodeSubChunkNoCache(y: number): Promise + + /** + * + * @param blobs The blob hashes sent in the SubChunk packet + * @param blobStore The Blob Store holding the chunk data + * @param payload The remaining data sent in the SubChunk packet, border blocks + */ + networkDecodeSubChunk(blobs: BigInt[], blobStore: IBlobStore, payload: Buffer): Promise + /** + * Encodes a cached subchunk for the section at y + * @param y The Y coordinate of the subchunk + * @param blobStore The cache storage + * @returns A hash of the encoded data (can be found in BlobStore) and a buffer containing block entities + */ + networkEncodeSubChunk(y: number, blobStore: IBlobStore): Promise<[BigInt, Buffer]> + + diskEncodeBlockEntities(): Buffer + diskDecodeBlockEntities(buffer: Buffer): void + + diskEncodeEntities(): Buffer + diskDecodeEntities(buffer: Buffer): void + + // Heightmap + loadHeights(map: Uint16Array): void + writeHeightMap(stream): void + + // + // Section management + getSection(pos): SubChunk + // Returns chunk at a Y index, adjusted for chunks at negative-Y + getSectionAtIndex(chunkY: number): SubChunk + // Creates a new air section + newSection(y: number): SubChunk + // Creates a new section with the given blocks + newSection(y: number, storageFormat: StorageType, buffer: Buffer): SubChunk + + // Block entities + addBlockEntity(tag: NBT): void + + // Entities + loadEntities(entities: NBT[]): void +} + +export class BlobEntry { + // The time this blob was added to the blob store + created: number + constructor(object: any) +} + +export const enum BlobType { + ChunkSection = 0, + Biomes = 1, +} + +export default function loader(mcVersionOrRegistry: string | Registry): typeof PCChunk | typeof BedrockChunk diff --git a/bridge/lib/prismarine-chunk/types/section.d.ts b/bridge/lib/prismarine-chunk/types/section.d.ts new file mode 100644 index 0000000..5b3bbad --- /dev/null +++ b/bridge/lib/prismarine-chunk/types/section.d.ts @@ -0,0 +1,47 @@ +import { Vec3 } from "vec3"; + +export = Section; +declare class Section { + static fromJson(j: any): Section; + static sectionSize(skyLightSent?: boolean): number; + constructor(skyLightSent?: boolean); + data: Buffer; + palette: number[]; + isDirty: boolean; + solidBlockCount: number; + toJson(): { + type: "Buffer"; + data: number[]; + }; + initialize(iniFunc: any): void; + getBiomeColor(pos: Vec3): { + r: number; + g: number; + b: number; + }; + setBiomeColor(pos: Vec3, r: number, g: number, b: number): void; + getBlockStateId(pos: Vec3): number; + getBlockType(pos: Vec3): number; + getBlockData(pos: Vec3): number; + getBlockLight(pos: Vec3): number; + getBlock(pos: number): number; + getSkyLight(pos: Vec3): number; + setBlockStateId(pos: Vec3, stateId: number): void; + setBlockType(pos: Vec3, id: number): void; + setBlockData(pos: Vec3, data: Buffer): void; + setBlockLight(pos: Vec3, light: number): void; + setBlock(pos: number, stateId: number): void; + setSkyLight(pos: Vec3, light: number): void; + dump(): Buffer; + load(data: Buffer, skyLightSent?: boolean): void; + isEmpty(): boolean; + write(smartBuffer: any): void; +} +declare namespace Section { + export { w }; + export { l }; + export { sh }; +} +declare const w: 16; +declare const l: 16; +declare const sh: 16; diff --git a/bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/bug_report.md b/bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..10442d1 --- /dev/null +++ b/bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug report +about: Create a report to help us improve +title: '' +labels: possible bug +assignees: '' + +--- + +- [ ] The [readme](https://github.com/PrismarineJS/prismarine-template/README.md) doesn't contain a resolution to my issue +- [ ] The [example](https://github.com/PrismarineJS/prismarine-template/example.js) doesn't contain a resolution to my issue + + + +## Versions + - node: #.#.# + - prismarine-template: #.#.# + +## Detailed description of a problem +A clear and concise description of what the problem is. + +## Your current code +```js +console.log("Hello world.") +``` + +## Expected behavior +A clear and concise description of what you expected to happen. + +## Additional context +Add any other context about the problem here. +--- diff --git a/bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/feature_request.md b/bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..61ba3ab --- /dev/null +++ b/bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,20 @@ +--- +name: Feature request +about: Suggest an idea for this project +title: '' +labels: new feature +assignees: '' + +--- + +## Is your feature request related to a problem? Please describe. +A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] + +## Describe the solution you'd like +A clear and concise description of what you want to happen. + +## Describe alternatives you've considered +A clear and concise description of any alternative solutions or features you've considered. + +## Additional context +Add any other context or screenshots about the feature request here. diff --git a/bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/question.md b/bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/question.md new file mode 100644 index 0000000..67191ee --- /dev/null +++ b/bridge/lib/prismarine-registry/.github/ISSUE_TEMPLATE/question.md @@ -0,0 +1,46 @@ +--- +name: Question +about: Ask a question +title: '' +labels: question +assignees: '' + +--- + +- [ ] The [readme](https://github.com/PrismarineJS/prismarine-template/README.md) doesn't contain a resolution to my issue +- [ ] The [example](https://github.com/PrismarineJS/prismarine-template/example.js) doesn't contain a resolution to my issue + + + + +## Versions + + - mineflayer: #.#.# + - server: vanilla/spigot/paper #.#.# + - node: #.#.# + +## Clear question + +A clear question, with as much context as possible. +What are you building? What problem are you trying to solve? + +## What did you try yet? + +Did you try any method from the API? +Did you try any example? Any error from those? + +## Your current code + +Please put here any custom code you tried yet. + +```js + +/* +Some code here, replace this +*/ + +``` + +## Additional context + +Add any other context about the problem here. diff --git a/bridge/lib/prismarine-registry/.github/dependabot.yml b/bridge/lib/prismarine-registry/.github/dependabot.yml new file mode 100644 index 0000000..4872c5a --- /dev/null +++ b/bridge/lib/prismarine-registry/.github/dependabot.yml @@ -0,0 +1,7 @@ +version: 2 +updates: +- package-ecosystem: npm + directory: "/" + schedule: + interval: daily + open-pull-requests-limit: 10 diff --git a/bridge/lib/prismarine-registry/.github/workflows/ci.yml b/bridge/lib/prismarine-registry/.github/workflows/ci.yml new file mode 100644 index 0000000..74edbe9 --- /dev/null +++ b/bridge/lib/prismarine-registry/.github/workflows/ci.yml @@ -0,0 +1,36 @@ +name: CI + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + + runs-on: ubuntu-latest + + strategy: + matrix: + node-version: [22.x] + + steps: + - uses: actions/checkout@v2 + - name: Use Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v1 + with: + node-version: ${{ matrix.node-version }} + - name: Setup Java JDK + uses: actions/setup-java@v1.4.3 + with: + java-version: 17 + java-package: jre + # Old versions of bedrock use old libssl that Ubuntu no longer ships with; need manual install + - name: (Linux) Install libssl 1.1 + if: runner.os == 'Linux' + run: | + wget http://archive.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1f-1ubuntu2_amd64.deb + sudo dpkg -i libssl1.1_1.1.1f-1ubuntu2_amd64.deb + - run: npm install + - run: npm test \ No newline at end of file diff --git a/bridge/lib/prismarine-registry/.github/workflows/commands.yml b/bridge/lib/prismarine-registry/.github/workflows/commands.yml new file mode 100644 index 0000000..2cf6379 --- /dev/null +++ b/bridge/lib/prismarine-registry/.github/workflows/commands.yml @@ -0,0 +1,22 @@ +name: Repo Commands + +on: + issue_comment: # Handle comment commands + types: [created] + pull_request: # Handle renamed PRs + types: [edited] + +jobs: + comment-trigger: + runs-on: ubuntu-latest + steps: + - name: Check out repository + uses: actions/checkout@v3 + - name: Run command handlers + uses: PrismarineJS/prismarine-repo-actions@master + with: + # NOTE: You must specify a Personal Access Token (PAT) with repo access here. While you can use the default GITHUB_TOKEN, actions taken with it will not trigger other actions, so if you have a CI workflow, commits created by this action will not trigger it. + token: ${{ secrets.PAT_PASSWORD }} + # See `Options` section below for more info on these options + install-command: npm install + /fixlint.fix-command: npm run fix diff --git a/bridge/lib/prismarine-registry/.github/workflows/publish.yml b/bridge/lib/prismarine-registry/.github/workflows/publish.yml new file mode 100644 index 0000000..f5e18ab --- /dev/null +++ b/bridge/lib/prismarine-registry/.github/workflows/publish.yml @@ -0,0 +1,32 @@ +name: npm-publish +on: + push: + branches: + - master # Change this to your default branch +jobs: + npm-publish: + name: npm-publish + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@master + - name: Set up Node.js + uses: actions/setup-node@master + with: + node-version: 22.0.0 + - id: publish + uses: JS-DevTools/npm-publish@v1 + with: + token: ${{ secrets.NPM_AUTH_TOKEN }} + - name: Create Release + if: steps.publish.outputs.type != 'none' + id: create_release + uses: actions/create-release@v1 + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + with: + tag_name: ${{ steps.publish.outputs.version }} + release_name: Release ${{ steps.publish.outputs.version }} + body: ${{ steps.publish.outputs.version }} + draft: false + prerelease: false \ No newline at end of file diff --git a/bridge/lib/prismarine-registry/.gitignore b/bridge/lib/prismarine-registry/.gitignore new file mode 100644 index 0000000..86bf329 --- /dev/null +++ b/bridge/lib/prismarine-registry/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +package-lock.json +.vscode +test/**/server_* +# mc test server +**/versions/*/*.json \ No newline at end of file diff --git a/bridge/lib/prismarine-registry/.gitpod b/bridge/lib/prismarine-registry/.gitpod new file mode 100644 index 0000000..061cf30 --- /dev/null +++ b/bridge/lib/prismarine-registry/.gitpod @@ -0,0 +1,4 @@ +image: + file: .gitpod.DockerFile +tasks: +- command: npm install diff --git a/bridge/lib/prismarine-registry/.gitpod.DockerFile b/bridge/lib/prismarine-registry/.gitpod.DockerFile new file mode 100644 index 0000000..4a553b0 --- /dev/null +++ b/bridge/lib/prismarine-registry/.gitpod.DockerFile @@ -0,0 +1,10 @@ +FROM gitpod/workspace-full:latest + +RUN bash -c ". /home/gitpod/.sdkman/bin/sdkman-init.sh \ + && sdk install java" +RUN bash -c ". .nvm/nvm.sh \ + && nvm install 14 \ + && nvm use 14 \ + && nvm alias default 14" + +RUN echo "nvm use default &>/dev/null" >> ~/.bashrc.d/51-nvm-fix \ No newline at end of file diff --git a/bridge/lib/prismarine-registry/.npmrc b/bridge/lib/prismarine-registry/.npmrc new file mode 100644 index 0000000..43c97e7 --- /dev/null +++ b/bridge/lib/prismarine-registry/.npmrc @@ -0,0 +1 @@ +package-lock=false diff --git a/bridge/lib/prismarine-registry/HISTORY.md b/bridge/lib/prismarine-registry/HISTORY.md new file mode 100644 index 0000000..81caecc --- /dev/null +++ b/bridge/lib/prismarine-registry/HISTORY.md @@ -0,0 +1,50 @@ +## History + +### 1.11.0 +* [Support for hashed block network ID's (#33)](https://github.com/PrismarineJS/prismarine-registry/commit/f3bc5c6f4f30c9226ff06115a0ea35632b81f19c) (thanks @FreezeEngine) +* [Bump mocha from 10.8.2 to 11.0.1 (#45)](https://github.com/PrismarineJS/prismarine-registry/commit/18a2d526ce6fa69824ef2b6d94442099e046d3f6) (thanks @dependabot[bot]) + +### 1.10.0 +* [Update chat_type handler (#42)](https://github.com/PrismarineJS/prismarine-registry/commit/065405410bcb13b00597c18ccc78a3749daafb23) (thanks @SuperGamerTron) +* [Disable bedrock test.](https://github.com/PrismarineJS/prismarine-registry/commit/09e80105f354af3405f18c965370621f785136d5) (thanks @rom1504) + +### 1.9.0 +* [Create commands.yml](https://github.com/PrismarineJS/prismarine-registry/commit/1252f261d171ac1398bfebbd124cd5b79477d684) (thanks @rom1504) +* [Remove debug logging (#40)](https://github.com/PrismarineJS/prismarine-registry/commit/711af2a979af8f76ce9c4f4c7c23701eaf2cb613) (thanks @extremeheat) +* [Increase timeout for bedrock more.](https://github.com/PrismarineJS/prismarine-registry/commit/ae86b03449a007dc5a1ae2d3354201f0df28c588) (thanks @rom1504) + +### 1.8.0 + +* pc 1.20.5 +* throw error on no data +* bedrock + +### 1.7.0 + +* Parse minecraft:raw chat type + +### 1.6.0 + +* chat for 1.19.2 + +### 1.5.0 +* Expose dimensions in pc1.19 codec + +### 1.4.0 +* Add handling for new pc chat formatting, expose new `chatFormattingById`/`chatFormattingByName` (#16) + +### 1.3.0 + +* update dimension codec functions for pc1.19 (#14) + +### 1.2.0 + +* Bump mcdata + +### 1.1.0 + +* Convert network biome schema to mcData schema + +### 1.0.0 + +* initial implementation diff --git a/bridge/lib/prismarine-registry/LICENSE b/bridge/lib/prismarine-registry/LICENSE new file mode 100644 index 0000000..ee939a6 --- /dev/null +++ b/bridge/lib/prismarine-registry/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 PrismarineJS + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/bridge/lib/prismarine-registry/README.md b/bridge/lib/prismarine-registry/README.md new file mode 100644 index 0000000..8918827 --- /dev/null +++ b/bridge/lib/prismarine-registry/README.md @@ -0,0 +1,75 @@ +# prismarine-registry +[![NPM version](https://img.shields.io/npm/v/prismarine-registry.svg)](http://npmjs.com/package/prismarine-registry) +[![Build Status](https://github.com/PrismarineJS/prismarine-registry/workflows/CI/badge.svg)](https://github.com/PrismarineJS/prismarine-registry/actions?query=workflow%3A%22CI%22) +[![Discord](https://img.shields.io/badge/chat-on%20discord-brightgreen.svg)](https://discord.gg/GsEFRM8) +[![Try it on gitpod](https://img.shields.io/badge/try-on%20gitpod-brightgreen.svg)](https://gitpod.io/#https://github.com/PrismarineJS/prismarine-registry) + +Creates an dynamic instance of node-minecraft-data. + +## Usage + +```js +const registry = require('prismarine-registry')('1.18') + +registry.blocksByName['stone'] // See information about stone +``` + +## API + +[See minecraft-data API](https://github.com/PrismarineJS/node-minecraft-data/blob/master/doc/api.md) + +### mcpc + +#### loadDimensionCodec / writeDimensionCodec + +* loads/writes data from dimension codec in login packet + +#### .chatFormattingByName, .chatFormattingById (1.19+) + +Contains mapping from chat type ID (numeric or string) to information about how the +chat type should be formatted and what the relevant parameters are. + +```js +{ + 'minecraft:chat': { formatString: '<%s> %s', parameters: [ 'sender', 'content' ] }, + 'minecraft:say_command': { formatString: '[%s] %s', parameters: [ 'sender', 'content' ] }, + 'minecraft:msg_command': { formatString: '%s whispers to you: %s', parameters: [ 'sender', 'content' ] }, + 'minecraft:team_msg_command': { formatString: '%s <%s> %s', parameters: [ 'team_name', 'sender', 'content' ] }, + 'minecraft:emote_command': { formatString: '* %s %s', parameters: [ 'sender', 'content' ] } +} +``` + +#### .dimensionsById, dimensionsByName (1.19+) + +Mapping to dimension data object containing dimension `name`, `minY` and `height`. + +### mcpe + +#### loadItemStates / writeItemStates + +* loads/writes data from an item states array inside the bedrock start game packet. + +```js +// In a client +const { createClient } = require('bedrock-protocol'); +const registry = require('prismarine-registry')('bedrock_1.19.50'); + +const client = createClient({ + 'host': '127.0.0.1' +}) + +client.on('start_game', ({ itemstates, block_network_ids_are_hashes }) => { + registry.handleStartGame({ itemstates, block_network_ids_are_hashes}); +}) + +client.on('item_registry', ({ itemstates }) => { + registry.handleStartGame({ itemstates }); +}) + +// In a server +server.on('connect', (client) => { + const itemstates = registry.writeItemStates() + client.write('start_game', { ...startGamePacket, itemstates }) // version < 1.21.70 + client.write('item_registry', { itemstates }) // version >= 1.21.70 +}) +``` \ No newline at end of file diff --git a/bridge/lib/prismarine-registry/example.js b/bridge/lib/prismarine-registry/example.js new file mode 100644 index 0000000..ceadd46 --- /dev/null +++ b/bridge/lib/prismarine-registry/example.js @@ -0,0 +1,3 @@ +const template = require('prismarine-template') + +template.helloWorld() diff --git a/bridge/lib/prismarine-registry/lib/bedrock/index.js b/bridge/lib/prismarine-registry/lib/bedrock/index.js new file mode 100644 index 0000000..4411a5f --- /dev/null +++ b/bridge/lib/prismarine-registry/lib/bedrock/index.js @@ -0,0 +1,128 @@ +const buildIndexFromArray = require('../indexer') + +module.exports = (data) => { + function loadItemStates (itemStates) { + const items = [] + for (const item of itemStates) { + const name = item.name.replace('minecraft:', '') + items.push({ ...data.itemsByName[name], name, id: item.runtime_id, nbt: item.nbt, version: item.version }) + } + data.itemsArray = items + data.items = buildIndexFromArray(data.itemsArray, 'id') + data.itemsByName = buildIndexFromArray(data.itemsArray, 'name') + } + function loadHashedRuntimeIds (registry) { + data.blocksArray = [...data.blocksArray] + data.blockStates = [...data.blockStates] + + const stateIndexToHash = {} + const Block = require('prismarine-block')(registry) + for (let i = 0; i < data.blockStates.length; i++) { + const { name, states } = data.blockStates[i] + const hash = Block.getHash(name, states) + stateIndexToHash[i] = hash + } + + for (let i = 0; i < data.blockStates.length; i++) { + data.blockStates[i] = { ...data.blockStates[i], stateId: stateIndexToHash[i] } + } + + for (let i = 0; i < data.blocksArray.length; i++) { + const item = data.blocksArray[i] = { ...data.blocksArray[i] } + item.defaultState = stateIndexToHash[item.defaultState] + item.states = [] + for (let stateId = item.minStateId; stateId <= item.maxStateId; stateId++) { + item.states.push(stateIndexToHash[stateId]) + } + item.maxStateId = undefined + item.minStateId = undefined + } + + data.blocks = buildIndexFromArray(data.blocksArray, 'id') + data.blocksByName = buildIndexFromArray(data.blocksArray, 'name') + data.blocksByStateId = {} + data.blocksByRuntimeId = {} + for (const block of data.blocksArray) { + for (const stateId of block.states) { + const stateBlock = {...block, stateId} + data.blocksByStateId[stateId] = stateBlock; + data.blocksByRuntimeId[stateId] = stateBlock + } + } + data.blockStates = buildIndexFromArray(data.blockStates, 'stateId') + } + function loadRuntimeIds () { + data.blocksArray = [...data.blocksArray] + data.blockStates = [...data.blockStates] + + for (let i = 0; i < data.blockStates.length; i++) { + data.blockStates[i] = { ...data.blockStates[i], stateId: i } + } + + for (let i = 0; i < data.blocksArray.length; i++) { + const item = data.blocksArray[i] = { ...data.blocksArray[i] } + item.states = [] + for (let stateId = item.minStateId; stateId <= item.maxStateId; stateId++) { + item.states.push(stateId) + } + } + + data.blocks = buildIndexFromArray(data.blocksArray, 'id') + data.blocksByName = buildIndexFromArray(data.blocksArray, 'name') + + data.blocksByStateId = {} + for (const block of data.blocksArray) { + for (const stateId of block.states) { + data.blocksByStateId[stateId] = block + } + } + } + + return { + handleStartGame (packet) { + if (packet.itemstates) { + loadItemStates(packet.itemstates) + } + + if (this.supportFeature('blockHashes') && packet.block_network_ids_are_hashes) { + loadHashedRuntimeIds(this) + } else { + loadRuntimeIds() + } + }, + handleItemRegistry (packet) { + if (packet.itemstates) { + loadItemStates(packet.itemstates) + } + }, + writeItemStates () { + const itemstates = [] + for (const item of data.itemsArray) { + // Custom items with different namespaces can also be in the palette + let [ns, name] = item.name.split(':') + if (!name) { + name = ns + ns = 'minecraft' + } + + const itemState = { + name: `${ns}:${name}`, + runtime_id: item.id, + component_based: ns !== 'minecraft' + } + + if (item.version !== undefined) { + itemState.version = item.version + } + + if (item.nbt !== undefined) { + itemState.nbt = item.nbt + } + + itemstates.push(itemState) + } + + return itemstates + } + } +} diff --git a/bridge/lib/prismarine-registry/lib/index.d.ts b/bridge/lib/prismarine-registry/lib/index.d.ts new file mode 100644 index 0000000..943c6f2 --- /dev/null +++ b/bridge/lib/prismarine-registry/lib/index.d.ts @@ -0,0 +1,25 @@ +import { IndexedData } from 'minecraft-data' +import { NBT } from 'prismarine-nbt' + +declare function loader(mcVersion: string): loader.Registry +declare namespace loader { + export interface RegistryPc extends IndexedData { + loadDimensionCodec(codec: NBT): void; + writeDimensionCodec(): NBT; + } + + export interface RegistryBedrock extends IndexedData { + handleStartGame(packet: any): void; + handleItemRegistry(packet: any): void; + writeItemStates(): ItemState[]; + } + + export type Registry = RegistryBedrock | RegistryPc + export type ItemState = { + name: string + runtime_id: number + component_based: boolean + } +} + +export = loader \ No newline at end of file diff --git a/bridge/lib/prismarine-registry/lib/index.js b/bridge/lib/prismarine-registry/lib/index.js new file mode 100644 index 0000000..f9939b1 --- /dev/null +++ b/bridge/lib/prismarine-registry/lib/index.js @@ -0,0 +1,18 @@ +const loader = require('./loader') + +module.exports = (version) => { + const staticData = require('minecraft-data')(version) + if (!staticData) { + throw new Error('Do not have data for ' + version) + } + const data = loader(staticData) + + let registry + if (version.startsWith('bedrock_')) { + registry = require('./bedrock')(data, staticData) + } else { + registry = require('./pc')(data, staticData) + } + + return Object.assign(data, registry) +} diff --git a/bridge/lib/prismarine-registry/lib/indexer.js b/bridge/lib/prismarine-registry/lib/indexer.js new file mode 100644 index 0000000..922d7e2 --- /dev/null +++ b/bridge/lib/prismarine-registry/lib/indexer.js @@ -0,0 +1,7 @@ +module.exports = function buildIndexFromArray (array, fieldToIndex) { + if (array === undefined) { return undefined } + return array.reduce(function (index, element) { + index[element[fieldToIndex]] = element + return index + }, {}) +} diff --git a/bridge/lib/prismarine-registry/lib/loader.js b/bridge/lib/prismarine-registry/lib/loader.js new file mode 100644 index 0000000..d046ec6 --- /dev/null +++ b/bridge/lib/prismarine-registry/lib/loader.js @@ -0,0 +1,4 @@ +module.exports = (mcData) => { + const data = Object.assign({}, mcData) + return data +} diff --git a/bridge/lib/prismarine-registry/lib/pc/index.js b/bridge/lib/prismarine-registry/lib/pc/index.js new file mode 100644 index 0000000..e57e55c --- /dev/null +++ b/bridge/lib/prismarine-registry/lib/pc/index.js @@ -0,0 +1,146 @@ +const nbt = require('prismarine-nbt') + +const { networkBiomesToMcDataSchema, mcDataSchemaToNetworkBiomes } = require('./transforms') + +module.exports = (data, staticData) => { + let hasDynamicDimensionData = false + + return { + loadDimensionCodec (codec) { + const handlers = { + chat_type (chat) { + data.chatFormattingById = {} + data.chatFormattingByName = {} + for (const chatType of chat) { + const d = chatType.element?.chat?.decoration ?? chatType.element?.chat + if (!d) continue + const n = { + id: chatType.id, + name: chatType.name, + formatString: staticData.language[d.translation_key] || d.translation_key /* chat type minecraft:raw has the formatString given directly by the translation key */, + parameters: d.parameters + } + if (staticData.supportFeature('incrementedChatType')) { + // 1.21 - Player chat packet chat type now starts at 1 + data.chatFormattingById[chatType.id + 1] = n + } else { + data.chatFormattingById[chatType.id] = n + } + data.chatFormattingByName[chatType.name] = n + } + }, + dimension_type (dimensions) { + data.dimensionsById = {} + data.dimensionsByName = {} + data.dimensionsArray = [] + + for (const { name, id, element } of dimensions) { + const n = name.replace('minecraft:', '') + const d = { + name: n, + minY: element.min_y, + height: element.height + } + data.dimensionsById[id] = d + data.dimensionsByName[n] = d + data.dimensionsArray.push(d) + } + }, + 'worldgen/biome' (biomes) { + data.biomes = [] + data.biomesByName = {} + data.biomesArray = [] + + biomes.map(e => networkBiomesToMcDataSchema(e, staticData)) + + const allBiomes = [] + for (const { name, id, element } of biomes) { + data.biomes[id] = element + data.biomesByName[name] = element + allBiomes.push(element) + } + data.biomesArray = allBiomes + + hasDynamicDimensionData = true + } + } + + if (staticData.supportFeature('segmentedRegistryCodecData')) { + // 1.20.5+ - dimension data is now seperated outside the NBT and is sent through + // multiple registry_data { id: registryName, entries: [key, registryData] } packets... + const entries = codec.entries.map((e, ix) => ({ id: ix, name: e.key, element: nbt.simplify(e.value) })) + handlers[codec.id.replace('minecraft:', '')]?.(entries) + } else { + const dimensionCodec = nbt.simplify(codec) + for (const codecName in dimensionCodec) { + handlers[codecName.replace('minecraft:', '')]?.(dimensionCodec[codecName].value) + } + } + }, + + writeDimensionCodec () { + const codec = {} + + if (data.version['<']('1.16')) { + return codec // no dimension codec in 1.15 + } else if (data.version['<']('1.16.2')) { + return staticData.loginPacket.dimensionCodec + } else if (data.version['<']('1.20.5')) { + // Keep the old dimension codec data if it exists (re-encoding) + // We don't have this data statically, should probably be added to mcData + + if (data.dimensionsArray) { + codec['minecraft:dimension_type'] = nbt.comp({ + type: nbt.string('minecraft:dimension_type'), + value: nbt.list(nbt.comp( + data.dimensionsArray.map(dimension => ({ + name: dimension.name, + id: dimension.id, + element: { + min_y: dimension.minY + } + })) + )) + }) + } else { + codec['minecraft:dimension_type'] = staticData.loginPacket.dimensionCodec.value['minecraft:dimension_type'] + } + + // if we have dynamic biome data (re-encoding), we can count on biome.effects + // being in place. Otherwise, we need to use static data exclusively, e.g. flying squid. + codec['minecraft:worldgen/biome'] = nbt.comp({ + type: nbt.string('minecraft:worldgen/biome'), + value: nbt.list(nbt.comp(mcDataSchemaToNetworkBiomes(hasDynamicDimensionData ? data.biomesArray : null, staticData))) + }) + // 1.19 + codec['minecraft:chat_type'] = staticData.loginPacket.dimensionCodec?.value?.['minecraft:chat_type'] + // NBT + return nbt.comp(codec) + } else { + if (data.dimensionsArray) { + codec['minecraft:dimension_type'] = { + id: 'minecraft:dimension_type', + entries: data.dimensionsArray.map(dimension => ({ + key: dimension.name, + value: nbt.comp({ + min_y: dimension.minY + }) + })) + } + } else { + codec['minecraft:dimension_type'] = staticData.loginPacket.dimensionCodec['minecraft:dimension_type'] + } + // if we have dynamic biome data (re-encoding), we can count on biome.effects + // being in place. Otherwise, we need to use static data exclusively, e.g. flying squid. + codec['minecraft:worldgen/biome'] = { + id: 'minecraft:worldgen/biome', + entries: nbt.comp(mcDataSchemaToNetworkBiomes(hasDynamicDimensionData ? data.biomesArray : null, staticData)) + } + // 1.19 + codec['minecraft:chat_type'] = staticData.loginPacket.dimensionCodec?.['minecraft:chat_type'] + // No NBT at root anymore + return codec + } + } + } +} diff --git a/bridge/lib/prismarine-registry/lib/pc/transforms.js b/bridge/lib/prismarine-registry/lib/pc/transforms.js new file mode 100644 index 0000000..4825b2d --- /dev/null +++ b/bridge/lib/prismarine-registry/lib/pc/transforms.js @@ -0,0 +1,75 @@ +const nbt = require('prismarine-nbt') + +module.exports = { + networkBiomesToMcDataSchema (biome, staticData) { + const name = biome.name.replace('minecraft:', '') + const equivalent = staticData.biomesByName[name] + return Object.assign(biome.element, { + ...equivalent, + id: biome.id, + name, + category: biome.element.category, + temperature: biome.element.temperature, + depth: biome.element.depth, + scale: biome.element.scale, + precipitation: biome.element.precipitation, + rainfall: biome.element.downfall + }) + }, + + mcDataSchemaToNetworkBiomes (dynBiomeData, staticData) { + const ret = [] + + if (!dynBiomeData) { + for (const biome of staticData.biomesArray) { + ret.push({ + name: nbt.string('minecraft:' + biome.name), + id: nbt.int(biome.id), + element: nbt.comp({ + depth: nbt.float(biome.depth), + scale: nbt.float(biome.scale), + category: nbt.string(biome.category), + temperature: nbt.float(biome.temperature), + precipitation: nbt.string(biome.precipitation), + downfall: nbt.float(biome.rainfall), + effects: { + // TODO: we need to add more data to static biomes.json + sky_color: nbt.int(biome.color) + } + }) + }) + } + } else { + for (const biome of dynBiomeData) { + const oldEffects = Object.entries(biome.effects).map(([k, v]) => ({ + [k]: nbt.int(v) + })).reduce((a, b) => Object.assign(a, b), {}) + ret.push({ + name: nbt.string('minecraft:' + biome.name), + id: nbt.int(biome.id), + element: nbt.comp({ + depth: nbt.float(biome.depth), + scale: nbt.float(biome.scale), + category: nbt.string(biome.category), + temperature: nbt.float(biome.temperature), + precipitation: nbt.string(biome.precipitation), + downfall: nbt.float(biome.rainfall), + effects: nbt.comp({ + ...oldEffects, + mood_sound: biome.effects?.mood_sound + ? nbt.comp({ + tick_delay: nbt.int(biome.effects.mood_sound.tick_delay), + offset: nbt.double(biome.effects.mood_sound.offset), + sound: nbt.string(biome.effects.mood_sound.sound), + block_search_extent: nbt.int(biome.effects.mood_sound.block_search_extent) + }) + : undefined + }) + }) + }) + } + } + + return ret + } +} diff --git a/bridge/lib/prismarine-registry/package.json b/bridge/lib/prismarine-registry/package.json new file mode 100644 index 0000000..bf7eb3f --- /dev/null +++ b/bridge/lib/prismarine-registry/package.json @@ -0,0 +1,41 @@ +{ + "name": "prismarine-registry", + "version": "1.11.0", + "description": "Prismarine Registry", + "main": "lib/index.js", + "scripts": { + "test": "mocha --reporter spec --bail --exit", + "pretest": "npm run lint", + "lint": "standard", + "fix": "standard --fix" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/PrismarineJS/prismarine-registry.git" + }, + "keywords": [ + "prismarine", + "template" + ], + "author": "Romain Beaumont", + "license": "MIT", + "bugs": { + "url": "https://github.com/PrismarineJS/prismarine-registry/issues" + }, + "homepage": "https://github.com/PrismarineJS/prismarine-registry#readme", + "devDependencies": { + "bedrock-protocol": "^3.22.0", + "debug": "^4.3.3", + "minecraft-bedrock-server": "^1.1.2", + "minecraft-protocol": "^1.30.0", + "minecraft-wrap": "^1.4.0", + "mocha": "^11.0.1", + "prismarine-registry": "file:.", + "standard": "^17.0.0" + }, + "dependencies": { + "minecraft-data": "^3.70.0", + "prismarine-block": "^1.17.1", + "prismarine-nbt": "^2.0.0" + } +} diff --git a/bridge/src/index.js b/bridge/src/index.js index 1a05d07..346fbfa 100644 --- a/bridge/src/index.js +++ b/bridge/src/index.js @@ -157,12 +157,23 @@ bot.once('spawn', () => { }); }); -bot.on('chat', (sender, message) => { - if (sender === username) return; // Ignore own messages +// Bedrock chat: listen on the raw text packet for reliable message capture +// The 'chat' event depends on regex matching which may not work for Bedrock format +bot._client.on('text', (packet) => { + if (packet.type !== 'chat') return; + const sender = packet.source_name || ''; + const message = packet.message || ''; + if (!sender || sender === username) return; log('client', 'INFO', `Chat: <${sender}> ${message}`); sendEvent('chat_message', { sender, message }); }); +// Also keep the mineflayer chat event as backup +bot.on('chat', (sender, message) => { + // Already handled by text packet listener above in most cases + // This catches any messages that come through the pattern system +}); + bot.on('health', () => { sendEvent('health_changed', { health: bot.health, diff --git a/dougbot/bridge/node_manager.py b/dougbot/bridge/node_manager.py index a7d9d2c..50c4ecf 100644 --- a/dougbot/bridge/node_manager.py +++ b/dougbot/bridge/node_manager.py @@ -204,6 +204,10 @@ class NodeManager(QObject): if self._process: data = self._process.readAllStandardError().data().decode("utf-8", errors="replace") for line in data.strip().split("\n"): - if line: - log.warn(f"[bridge stderr] {line}") - self.log_output.emit(f"[STDERR] {line}") + if not line: + continue + # Filter out harmless Node.js warnings + if "DEP0040" in line or "punycode" in line or "trace-deprecation" in line: + continue + log.warn(f"[bridge stderr] {line}") + self.log_output.emit(f"[STDERR] {line}") diff --git a/dougbot/core/brain.py b/dougbot/core/brain.py index b898ca6..089339b 100644 --- a/dougbot/core/brain.py +++ b/dougbot/core/brain.py @@ -105,8 +105,13 @@ class DougBrain(QObject): log.debug("Movement timed out, stopping") # Request current status from bridge + # Safety: reset pending flag if it's been stuck for more than 10 seconds + if self._pending_status and (time.time() - self._action_start_time > 10): + self._pending_status = False + if not self._pending_status: self._pending_status = True + self._action_start_time = time.time() self._ws.send_request("status", {}, self._on_status) self._ws.send_request("get_nearby_entities", {"radius": 16}, self._on_entities) diff --git a/dougbot/db/connection.py b/dougbot/db/connection.py index a6732f8..02d2397 100644 --- a/dougbot/db/connection.py +++ b/dougbot/db/connection.py @@ -150,33 +150,50 @@ class MariaDBConnection(DatabaseConnection): query = re.sub(r'(? Any: self._ensure_connected() query = self._prepare(query) - cursor = self._conn.cursor(dictionary=True) - cursor.execute(query, params) - self._conn.commit() - return cursor + def _do(): + cursor = self._conn.cursor(dictionary=True) + cursor.execute(query, params) + self._conn.commit() + return cursor + return self._retry_on_disconnect(_do) def executemany(self, query: str, params_list: list[tuple]) -> None: self._ensure_connected() query = self._prepare(query) - cursor = self._conn.cursor() - cursor.executemany(query, params_list) + def _do(): + cursor = self._conn.cursor() + cursor.executemany(query, params_list) + self._retry_on_disconnect(_do) def fetchone(self, query: str, params: tuple = ()) -> Optional[dict]: self._ensure_connected() query = self._prepare(query) - cursor = self._conn.cursor(dictionary=True) - cursor.execute(query, params) - return cursor.fetchone() + def _do(): + cursor = self._conn.cursor(dictionary=True) + cursor.execute(query, params) + return cursor.fetchone() + return self._retry_on_disconnect(_do) def fetchall(self, query: str, params: tuple = ()) -> list[dict]: self._ensure_connected() query = self._prepare(query) - cursor = self._conn.cursor(dictionary=True) - cursor.execute(query, params) - return cursor.fetchall() + def _do(): + cursor = self._conn.cursor(dictionary=True) + cursor.execute(query, params) + return cursor.fetchall() + return self._retry_on_disconnect(_do) def commit(self) -> None: self._conn.commit()