- 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>
624 lines
17 KiB
TypeScript
624 lines
17 KiB
TypeScript
/**
|
|
* 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,
|
|
};
|
|
}
|