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) <noreply@anthropic.com>
This commit is contained in:
roberts 2026-03-30 12:33:17 -05:00
parent d0a96ce028
commit 8f616598fd
217 changed files with 36399 additions and 17 deletions

2
.gitignore vendored
View file

@ -3,6 +3,8 @@ __pycache__/
.env
node_modules/
bridge/dist/
bridge/lib/minecraft-data/
bridge/lib/*/node_modules/
*.egg-info/
.eggs/
dist/

2
bridge/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
bridge/lib/
bridge/node_modules/

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

13
bridge/lib/mineflayer/.gitignore vendored Normal file
View file

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

View file

@ -0,0 +1,2 @@
tasks:
- command: npm install && sdk install java

View file

@ -0,0 +1,3 @@
# shared with .gitignore
# different than .gitignore
test

View file

@ -0,0 +1,2 @@
engine-strict=true
package-lock=false

View file

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

6351
bridge/lib/mineflayer/bedrock-types.d.ts vendored Normal file

File diff suppressed because it is too large Load diff

878
bridge/lib/mineflayer/index.d.ts vendored Normal file
View file

@ -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<number, any>;
dimensionsByName?: Record<string, { minY: number; height: number }>;
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<BotOptions>): 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> | void;
whisper: (username: string, message: string, translate: string | null, jsonMsg: ChatMessage, matches: string[] | null) => Promise<void> | void;
actionBar: (jsonMsg: ChatMessage) => Promise<void> | void;
error: (err: Error) => Promise<void> | void;
message: (jsonMsg: ChatMessage, position: string) => Promise<void> | void;
messagestr: (message: string, position: string, jsonMsg: ChatMessage) => Promise<void> | void;
unmatchedMessage: (stringMsg: string, jsonMsg: ChatMessage) => Promise<void> | void;
inject_allowed: () => Promise<void> | void;
login: () => Promise<void> | void;
/** When `respawn` option is disabled, you can call this method manually to respawn. */
spawn: () => Promise<void> | void;
respawn: () => Promise<void> | void;
game: () => Promise<void> | void;
title: (text: string, type: 'subtitle' | 'title') => Promise<void> | void;
rain: () => Promise<void> | void;
time: () => Promise<void> | void;
kicked: (reason: string, loggedIn: boolean) => Promise<void> | void;
end: (reason: string) => Promise<void> | void;
spawnReset: () => Promise<void> | void;
death: () => Promise<void> | void;
health: () => Promise<void> | void;
breath: () => Promise<void> | void;
entitySwingArm: (entity: Entity) => Promise<void> | void;
entityHurt: (entity: Entity, source: Entity) => Promise<void> | void;
entityDead: (entity: Entity) => Promise<void> | void;
entityTaming: (entity: Entity) => Promise<void> | void;
entityTamed: (entity: Entity) => Promise<void> | void;
entityShakingOffWater: (entity: Entity) => Promise<void> | void;
entityEatingGrass: (entity: Entity) => Promise<void> | void;
entityHandSwap: (entity: Entity) => Promise<void> | void;
entityWake: (entity: Entity) => Promise<void> | void;
entityEat: (entity: Entity) => Promise<void> | void;
entityCriticalEffect: (entity: Entity) => Promise<void> | void;
entityMagicCriticalEffect: (entity: Entity) => Promise<void> | void;
entityCrouch: (entity: Entity) => Promise<void> | void;
entityUncrouch: (entity: Entity) => Promise<void> | void;
entityEquip: (entity: Entity) => Promise<void> | void;
entitySleep: (entity: Entity) => Promise<void> | void;
entitySpawn: (entity: Entity) => Promise<void> | void;
entityElytraFlew: (entity: Entity) => Promise<void> | void;
usedFirework: () => Promise<void> | void;
itemDrop: (entity: Entity) => Promise<void> | void;
playerCollect: (collector: Entity, collected: Entity) => Promise<void> | void;
entityAttributes: (entity: Entity) => Promise<void> | void;
entityGone: (entity: Entity) => Promise<void> | void;
entityMoved: (entity: Entity) => Promise<void> | void;
entityDetach: (entity: Entity, vehicle: Entity) => Promise<void> | void;
entityAttach: (entity: Entity, vehicle: Entity) => Promise<void> | void;
entityUpdate: (entity: Entity) => Promise<void> | void;
entityEffect: (entity: Entity, effect: Effect) => Promise<void> | void;
entityEffectEnd: (entity: Entity, effect: Effect) => Promise<void> | void;
playerJoined: (player: Player) => Promise<void> | void;
playerUpdated: (player: Player) => Promise<void> | void;
playerLeft: (entity: Player) => Promise<void> | void;
blockUpdate: (oldBlock: Block | null, newBlock: Block) => Promise<void> | void;
'blockUpdate:(x, y, z)': (oldBlock: Block | null, newBlock: Block | null) => Promise<void> | void;
chunkColumnLoad: (entity: Vec3) => Promise<void> | void;
chunkColumnUnload: (entity: Vec3) => Promise<void> | void;
soundEffectHeard: (soundName: string, position: Vec3, volume: number, pitch: number) => Promise<void> | void;
hardcodedSoundEffectHeard: (soundId: number, soundCategory: number, position: Vec3, volume: number, pitch: number) => Promise<void> | void;
noteHeard: (block: Block, instrument: Instrument, pitch: number) => Promise<void> | void;
pistonMove: (block: Block, isPulling: number, direction: number) => Promise<void> | void;
chestLidMove: (block: Block, isOpen: number | boolean, block2: Block | null) => Promise<void> | void;
blockBreakProgressObserved: (block: Block, destroyStage: number, entity?: Entity) => Promise<void> | void;
blockBreakProgressEnd: (block: Block, entity?: Entity) => Promise<void> | void;
diggingCompleted: (block: Block) => Promise<void> | void;
diggingAborted: (block: Block) => Promise<void> | void;
move: (position: Vec3) => Promise<void> | void;
forcedMove: () => Promise<void> | void;
mount: () => Promise<void> | void;
dismount: (vehicle: Entity) => Promise<void> | void;
windowOpen: (window: Window) => Promise<void> | void;
windowClose: (window: Window) => Promise<void> | void;
sleep: () => Promise<void> | void;
wake: () => Promise<void> | void;
experience: () => Promise<void> | void;
physicsTick: () => Promise<void> | void;
physicTick: () => Promise<void> | void;
scoreboardCreated: (scoreboard: ScoreBoard) => Promise<void> | void;
scoreboardDeleted: (scoreboard: ScoreBoard) => Promise<void> | void;
scoreboardTitleChanged: (scoreboard: ScoreBoard) => Promise<void> | void;
scoreUpdated: (scoreboard: ScoreBoard, item: number) => Promise<void> | void;
scoreRemoved: (scoreboard: ScoreBoard, item: number) => Promise<void> | void;
scoreboardPosition: (position: DisplaySlot, scoreboard: ScoreBoard) => Promise<void> | void;
teamCreated: (team: Team) => Promise<void> | void;
teamRemoved: (team: Team) => Promise<void> | void;
teamUpdated: (team: Team) => Promise<void> | void;
teamMemberAdded: (team: Team) => Promise<void> | void;
teamMemberRemoved: (team: Team) => Promise<void> | void;
bossBarCreated: (bossBar: BossBar) => Promise<void> | void;
bossBarDeleted: (bossBar: BossBar) => Promise<void> | void;
bossBarUpdated: (bossBar: BossBar) => Promise<void> | void;
resourcePack: (url: string, hash?: string, uuid?: string) => Promise<void> | void;
particle: (particle: Particle) => Promise<void> | 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<BotEvents> {
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<StorageEvents>;
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<string[]>;
chat: (message: string) => void;
whisper: (username: string, message: string) => void;
chatAddPattern: (pattern: RegExp, chatType: string, description?: string) => number;
setSettings: (options: Partial<GameSettings>) => void;
loadPlugin: (plugin: Plugin) => void;
loadPlugins: (plugins: Plugin[]) => void;
hasPlugin: (plugin: Plugin) => boolean;
sleep: (bedBlock: Block) => Promise<void>;
isABed: (bedBlock: Block) => boolean;
wake: () => Promise<void>;
elytraFly: () => Promise<void>;
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<void>;
look: (yaw: number, pitch: number, force?: boolean) => Promise<void>;
updateSign: (block: Block, text: string, back?: boolean) => void;
equip: (item: Item | number, destination: EquipmentDestination | null) => Promise<void>;
unequip: (destination: EquipmentDestination | null) => Promise<void>;
tossStack: (item: Item) => Promise<void>;
toss: (itemType: number, metadata: number | null, count: number | null) => Promise<void>;
dig: ((block: Block, forceLook?: boolean | 'ignore') => Promise<void>) & ((block: Block, forceLook: boolean | 'ignore', digFace: 'auto' | Vec3 | 'raycast') => Promise<void>);
stopDigging: () => void;
digTime: (block: Block) => number;
placeBlock: (referenceBlock: Block, faceVector: Vec3) => Promise<void>;
placeEntity: (referenceBlock: Block, faceVector: Vec3) => Promise<Entity>;
activateBlock: (block: Block, direction?: Vec3, cursorPos?: Vec3) => Promise<void>;
activateEntity: (entity: Entity) => Promise<void>;
activateEntityAt: (entity: Entity, position: Vec3) => Promise<void>;
consume: () => Promise<void>;
fish: () => Promise<void>;
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<void>;
writeBook: (slot: number, pages: string[]) => Promise<void>;
openContainer: (chest: Block | Entity, direction?: Vec3, cursorPos?: Vec3) => Promise<Chest | Dispenser>;
openChest: (chest: Block | Entity, direction?: number, cursorPos?: Vec3) => Promise<Chest>;
openFurnace: (furnace: Block) => Promise<Furnace>;
openDispenser: (dispenser: Block) => Promise<Dispenser>;
openEnchantmentTable: (enchantmentTable: Block) => Promise<EnchantmentTable>;
openAnvil: (anvil: Block) => Promise<Anvil>;
openVillager: (villager: Entity) => Promise<Villager>;
trade: (villagerInstance: Villager, tradeIndex: string | number, times?: number) => Promise<void>;
setCommandBlock: (pos: Vec3, command: string, options: CommandBlockOptions) => void;
clickWindow: (slot: number, mouseButton: number, mode: number) => Promise<void>;
putSelectedItemRange: (start: number, end: number, window: Window, slot: any) => Promise<void>;
putAway: (slot: number) => Promise<void>;
closeWindow: (window: Window) => void;
transfer: (options: TransferOptions) => Promise<void>;
openBlock: (block: Block, direction?: Vec3, cursorPos?: Vec3) => Promise<Window>;
openEntity: (block: Entity, Class: new () => EventEmitter) => Promise<Window>;
openInventory: () => Promise<void>;
moveSlotItem: (sourceSlot: number, destSlot: number) => Promise<void>;
updateHeldItem: () => void;
getEquipmentDestSlot: (destination: string) => number;
getNextItemStackRequestId: () => number;
waitForChunksToLoad: () => Promise<void>;
entityAtCursor: (maxDistance?: number) => Entity | null;
nearestEntity: (filter?: (entity: Entity) => boolean) => Entity | null;
waitForTicks: (ticks: number) => Promise<void>;
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<string>;
acceptResourcePack: () => void;
denyResourcePack: () => void;
respawn: () => void;
close: () => void;
cameraState: { pitch: number; yaw: number };
item_registry_task: { promise: Promise<void> } | null;
}
/**
* Bedrock client with typed packet event handlers.
* Use this for explicit typing when you need typed packet events.
*/
export interface BedrockClient extends TypedEmitter<protocolTypes.BedrockPacketEventMap & { login: () => void; join: () => void }> {
username: string;
write<K extends keyof protocolTypes.BedrockPacketEventMap>(name: K, params: Parameters<protocolTypes.BedrockPacketEventMap[K]>[0]): void;
queue<K extends keyof protocolTypes.BedrockPacketEventMap>(name: K, params: Parameters<protocolTypes.BedrockPacketEventMap[K]>[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<string, string>;
// 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<void>;
rightMouse: (slot: number) => Promise<void>;
}
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<void>;
clearSlot: (slot: number) => Promise<void>;
clearInventory: () => Promise<void>;
flyTo: (destination: Vec3) => Promise<void>;
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<StorageEvents> {
constructor();
close(): void;
deposit(itemType: number, metadata: number | null, count: number | null): Promise<void>;
withdraw(itemType: number, metadata: number | null, count: number | null): Promise<void>;
}
export class Furnace extends Window<FurnaceEvents> {
fuel: number;
progress: number;
constructor();
close(): void;
takeInput(): Promise<Item>;
takeFuel(): Promise<Item>;
takeOutput(): Promise<Item>;
putInput(itemType: number, metadata: number | null, count: number): Promise<void>;
putFuel(itemType: number, metadata: number | null, count: number): Promise<void>;
inputItem(): Item;
fuelItem(): Item;
outputItem(): Item;
}
export class Dispenser extends Window<StorageEvents> {
constructor();
close(): void;
deposit(itemType: number, metadata: number | null, count: number | null): Promise<void>;
withdraw(itemType: number, metadata: number | null, count: number | null): Promise<void>;
}
export class EnchantmentTable extends Window<ConditionalStorageEvents> {
enchantments: Enchantment[];
constructor();
close(): void;
targetItem(): Item;
enchant(choice: string | number): Promise<Item>;
takeTargetItem(): Promise<Item>;
putTargetItem(item: Item): Promise<Item>;
putLapis(item: Item): Promise<Item>;
}
export class Anvil {
combine(itemOne: Item, itemTwo: Item, name?: string): Promise<void>;
rename(item: Item, name?: string): Promise<void>;
}
export interface Enchantment {
level: number;
expected: { enchant: number; level: number };
}
export class Villager extends Window<ConditionalStorageEvents> {
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;

View file

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

View file

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

View file

@ -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<number> {
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<number> {
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<number> {
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 };
}

View file

@ -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<number, BedrockRecipe[]>, 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<string, { ing: RecipeIngredient; count: number }>();
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<void> {
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<number, { slot: number; stackId: number; count: number }>();
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;
}

View file

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

View file

@ -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<ItemStackResult> {
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<boolean> {
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,
};
}

View file

@ -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];
}

View file

@ -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<void>;
/** Combine items (repair) */
combine: () => Promise<void>;
/** Put item in first slot */
putTarget: (itemType: number | string, metadata: number | null) => Promise<void>;
/** Put item in material slot */
putMaterial: (itemType: number | string, metadata: number | null, count: number) => Promise<void>;
/** Take result */
takeResult: () => Promise<Item | null>;
/** 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<Anvil> {
// 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<void> {
// 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<void> {
// 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<void> {
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<void> {
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<Item | null> {
// Result is taken via combine/rename actions
return null;
},
close() {
bot.closeWindow(window);
},
};
}

View file

@ -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<void>;
/** Put ingredient (nether wart, sugar, etc.) */
putIngredient: (itemType: number | string, metadata: number | null) => Promise<void>;
/** Put potion bottle in result slot (1-3) */
putBottle: (slot: 1 | 2 | 3, itemType: number | string, metadata: number | null) => Promise<void>;
/** Take bottle from result slot (1-3) */
takeBottle: (slot: 1 | 2 | 3) => Promise<Item | null>;
/** 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<BrewingStand> {
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<void> {
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<void> {
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<void> {
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<Item | null> {
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;
}

View file

@ -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<void>;
/** Put paper in additional slot (for extending) */
putPaper: () => Promise<void>;
/** Put empty map in additional slot (for cloning) */
putEmptyMap: () => Promise<void>;
/** Put glass pane in additional slot (for locking) */
putGlassPane: () => Promise<void>;
/** Execute the cartography operation (clone/extend/lock) */
craft: () => Promise<void>;
/** Take result from cartography table */
takeResult: () => Promise<Item | null>;
/** 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<CartographyTable> {
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<number> {
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<void> {
mapStackId = await putItem(ContainerIds.CARTOGRAPHY_INPUT, CartographySlots.INPUT, itemType, metadata);
},
async putPaper(): Promise<void> {
additionalStackId = await putItem(ContainerIds.CARTOGRAPHY_ADDITIONAL, CartographySlots.ADDITIONAL, 'paper', null);
},
async putEmptyMap(): Promise<void> {
additionalStackId = await putItem(ContainerIds.CARTOGRAPHY_ADDITIONAL, CartographySlots.ADDITIONAL, 'empty_map', null);
},
async putGlassPane(): Promise<void> {
additionalStackId = await putItem(ContainerIds.CARTOGRAPHY_ADDITIONAL, CartographySlots.ADDITIONAL, 'glass_pane', null);
},
async craft(): Promise<void> {
// 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<Item | null> {
// 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);
},
};
}

View file

@ -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<void>;
/** Put item to enchant */
putItem: (itemType: number | string, metadata: number | null) => Promise<void>;
/** Put lapis lazuli */
putLapis: (count: number) => Promise<void>;
/** Take enchanted item back */
takeItem: () => Promise<Item | null>;
/** 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<EnchantmentTable> {
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<void> {
// 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<void> {
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<void> {
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<Item | null> {
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);
},
};
}

View file

@ -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<void>;
/** Put item in fuel slot */
putFuel: (itemType: number | string, metadata: number | null, count: number) => Promise<void>;
/** Take item from ingredient slot */
takeInput: () => Promise<Item | null>;
/** Take item from fuel slot */
takeFuel: () => Promise<Item | null>;
/** Take item from output slot */
takeOutput: () => Promise<Item | null>;
/** 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<Furnace> {
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<void> {
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<void> {
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<Item | null> {
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<Item | null> {
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<Item | null> {
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;
}

View file

@ -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<void>;
/** Disenchant the item (removes enchantments, returns XP) */
disenchant: () => Promise<void>;
/** Take result from grindstone */
takeResult: () => Promise<Item | null>;
/** 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<Grindstone> {
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<void> {
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<void> {
// 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<Item | null> {
// 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);
},
};
}

View file

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

View file

@ -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<void>;
/** Put dye in dye slot */
putDye: (itemType: number | string, metadata: number | null) => Promise<void>;
/** Apply pattern to banner (pattern selection is done via UI, this just confirms) */
applyPattern: () => Promise<void>;
/** Take result from loom */
takeResult: () => Promise<Item | null>;
/** 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<Loom> {
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<void> {
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<void> {
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<void> {
// 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<Item | null> {
// 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);
},
};
}

View file

@ -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<void>;
/** Put template in template slot */
putTemplate: (itemType: number | string, metadata: number | null) => Promise<void>;
/** Put item to upgrade */
putInput: (itemType: number | string, metadata: number | null) => Promise<void>;
/** Put upgrade material */
putMaterial: (itemType: number | string, metadata: number | null) => Promise<void>;
/** 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<SmithingTable> {
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<void> {
// 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<void> {
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<void> {
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<void> {
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);
},
};
}

View file

@ -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<void>;
/** Close the stonecutter */
close: () => void;
}
// ============================================================================
// Implementation
// ============================================================================
/**
* Open a stonecutter block and return interface for crafting
*/
export async function openStonecutter(bot: BedrockBot, stonecutterBlock: Block): Promise<Stonecutter> {
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<void> {
// 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);
},
};
}

View file

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

View file

@ -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<number, Vec3> = {
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<void> {
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<void> {
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<void> {
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
}

View file

@ -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<string, Record<string, string>> = {
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<string, number> = {};
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);
}
}
});
}

View file

@ -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<string, string> = {
'-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<string>();
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<void>((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<string>();
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
// }

View file

@ -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. CS: book_edit {action: "replace_page", slot, page, text}
* 4. CS: 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<string, unknown> = {
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<void> {
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<void> {
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<void>((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;
}

View file

@ -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<string, any> = {};
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);
},
});
}

View file

@ -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');
}
});
}

View file

@ -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<number, ChatPattern | undefined> = {};
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<string[]> {
// 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<string> {
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;
}

View file

@ -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<Window> {
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;
}

File diff suppressed because it is too large Load diff

View file

@ -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<number, number> = 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<number> = 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<void> {
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<void> {
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<void> {
// 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<void> {
return setInventorySlot(slotIndex, null);
}
/**
* Clear all inventory slots
*/
async function clearInventory(): Promise<void> {
const promises: Promise<void>[] = [];
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<void> {
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<void>((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<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}

View file

@ -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<typeof setInterval> | null = null;
let continueBreakInterval: ReturnType<typeof setInterval> | null = null;
let waitTimeout: ReturnType<typeof setTimeout> | 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<void> {
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;
}

View file

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

View file

@ -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');
});
}

View file

@ -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<void> {
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
}

View file

@ -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
// })
// })
}

View file

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

View file

@ -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<string, any>, obj2: Record<string, any>): Record<string, any> {
var result: Record<string, any> = {};
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<string, any>) {
var name;
for (name in obj) {
return false;
}
return true;
}
}

File diff suppressed because it is too large Load diff

View file

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

View file

@ -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);
};
}

View file

@ -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)));
});
}

View file

@ -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<typeof setInterval> | 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<void>((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<void>((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);
}

View file

@ -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');
}
});
}

View file

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

View file

@ -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<string, any> = {};
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;
}

View file

@ -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<string, number> = {
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<void> {
// 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<void> {
if (!destination) {
destination = 'hand';
}
if (destination === 'hand') {
await equipEmpty();
} else {
await disrobe(destination);
}
}
async function equipEmpty(): Promise<void> {
// 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<void> {
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<void> {
// 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<void> {
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<void> {
return bot.clickWindow(slot, 0, 0);
}
async function rightMouse(slot: number): Promise<void> {
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)
}

View file

@ -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);
}
});
}

View file

@ -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');
});
}

View file

@ -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(''),
};
}

View file

@ -0,0 +1,7 @@
import type { BedrockBot } from '../../index.js';
export default function inject(bot: BedrockBot) {
// Unsupported in bedrock
bot.teams = {};
bot.teamMap = {};
}

View file

@ -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');
});
}

View file

@ -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');
}
});
}

View file

@ -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<VillagerTrade[]> {
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<Villager> {
// 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<Window>((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<void> {
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<void> {
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;
}

View file

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

View file

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

View file

@ -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<string, any>): void;
/**
* Close the logger and flush any pending writes
*/
close(): void;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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<string, string> = {
'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, '');
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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')
}
}
})
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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')
})
}

View file

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

View file

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

View file

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

View file

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

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