import type { InventoryAction, InventoryWindowState, PlayerState, SlotState, ItemStack } from '../types' import type { InventoryConnector, ConnectorListener, ConnectorEvent, MineflayerBot } from './types' import { getInventoryType } from '../registry' import { createInventoryDebugSession, getActionSlotIndexes, sanitizeDebugValue, summarizeAction, summarizeItem, summarizeSlots, summarizeWindowState, } from '../debug/inventoryDebug' type RawSlot = { type: number; count: number; metadata?: number; nbt?: unknown } /** * Options for {@link createMineflayerConnector}. */ export interface MineflayerConnectorOptions { /** * Custom item mapper called for every slot conversion from raw mineflayer data to * {@link ItemStack}. Receives the raw slot data and the default-mapped stack. * Return a modified stack to override fields (e.g. `name`, `textureKey`, `displayName`, * `texture`, `blockTexture`), or return the second argument unchanged to use the default mapping. * * @example * ```ts * createMineflayerConnector(bot, { * itemMapper: (raw, mapped) => ({ * ...mapped, * textureKey: raw.type === 438 ? 'item/potion_water' : mapped.textureKey, * }), * }) * ``` * * @example Block texture with isometric face slices * ```ts * itemMapper: (raw, mapped) => ({ * ...mapped, * blockTexture: { * source: blockAtlasUrl, * top: { slice: [0, 0, 16, 16] }, * left: { slice: [16, 0, 16, 16] }, * right: { slice: [32, 0, 16, 16] }, * }, * }) * ``` */ itemMapper?: (raw: RawSlot, mapped: ItemStack) => ItemStack /** * When true, the connector only tracks the player inventory (window 0) and never * emits windowOpen/windowUpdate/windowClose events. Use for HUD hotbar that must * always show player hotbar slots regardless of open container windows. */ hotbarOnly?: boolean /** * Optional title formatter. Called with the raw window title from the server * (may be a JSON text component string, NBT object, or plain text). * Return a human-readable string for display. */ formatTitle?: (rawTitle: any) => string /** * Optional client-side anvil cost calculator. Called when items in an anvil * window change to compute repairCost instantly (before the server's * craft_progress_bar packet arrives). Receives the two input slot items. * Return the XP cost, or null/0 to clear. Server packets will override this. * * @example Using prismarine-item: * ```ts * const Item = require('prismarine-item')(bot.registry) * createMineflayerConnector(bot, { * computeAnvilCost: (item1, item2) => { * if (!item1) return null * const { xpCost } = Item.anvil(item1, item2, bot.game.gameMode === 'creative') * return xpCost * }, * }) * ``` */ computeAnvilCost?: (itemOne: RawSlot | null, itemTwo: RawSlot | null) => number | null } function makeSlotConverter(itemMapper?: MineflayerConnectorOptions['itemMapper']) { return function botSlotToItemStack(slot: RawSlot | null | undefined): ItemStack | null { if (!slot || slot.type === -1 || slot.type === 0) return null const mapped: ItemStack = { type: slot.type, count: slot.count, metadata: slot.metadata, nbt: slot.nbt as Record | undefined, // Default debug key: ":" — visible as data-debug on slot elements. // Override via itemMapper if needed. debugKey: slot.metadata ? `${slot.type}:${slot.metadata}` : String(slot.type), } return itemMapper ? itemMapper(slot, mapped) : mapped } } function botSlotsToSlotStates( slots: MineflayerBot['inventory']['slots'], convert: (slot: RawSlot | null | undefined) => ItemStack | null, ): SlotState[] { return slots.map((slot, index) => ({ index, item: convert(slot), })) } function modeFromAction(action: InventoryAction): [number, number] { if (action.type !== 'click') return [0, 0] if (action.mode === 'shift') return [action.button === 'left' ? 0 : 1, 1] if (action.mode === 'number' && action.numberKey !== undefined) return [action.numberKey, 2] if (action.mode === 'middle') return [2, 3] if (action.mode === 'drop') return [action.button === 'left' ? 0 : 1, 4] if (action.mode === 'drag') return [action.button === 'left' ? 0 : 4, 5] if (action.mode === 'double') return [0, 6] return [action.button === 'left' ? 0 : 1, 0] } /** Extended bot API from mineflayer plugins (villager, enchantment_table, anvil, beacon). */ interface MineflayerBotExtended extends MineflayerBot { trade?(villager: unknown, tradeIndex: number, count?: number): Promise supportFeature?(name: string): boolean _client?: { write(packet: string, data: unknown): void on?(event: string, listener: (...args: any[]) => void): void off?(event: string, listener: (...args: any[]) => void): void removeListener?(event: string, listener: (...args: any[]) => void): void writeChannel?(channel: string, data: unknown): void registerChannel?(channel: string, schema: unknown): void } } function isVillagerWindow(win: unknown): win is { trade: (i: number, c?: number) => Promise } { return ( win != null && typeof (win as Record).trade === 'function' && Array.isArray((win as Record).trades) ) } function isEnchantmentTableWindow(win: unknown): win is { enchant: (choice: number) => Promise } { return win != null && typeof (win as Record).enchant === 'function' } function isAnvilWindow( win: unknown ): win is { rename: (item: unknown, name: string) => Promise findInventoryItem?: (id: number) => unknown slots?: Array<{ type?: number; metadata?: number; count?: number; nbt?: unknown } | null> } { return win != null && typeof (win as Record).rename === 'function' } function isBeaconWindow(win: unknown): win is { setBeaconEffects?: (primary: number, secondary: number) => Promise } { return win != null && (typeof (win as Record).setBeaconEffects === 'function' || /beacon/i.test(String((win as Record).type))) } export function createMineflayerConnector(bot: MineflayerBot, options?: MineflayerConnectorOptions): InventoryConnector { const listeners = new Set() const ext = bot as MineflayerBotExtended const convert = makeSlotConverter(options?.itemMapper) const hotbarOnly = options?.hotbarOnly ?? false const formatTitle = options?.formatTitle const computeAnvilCost = options?.computeAnvilCost // Track window properties (furnace progress, enchant levels, etc.) const windowProperties: Record = {} let currentWindowType: string | null = null // Track stateId for raw packet sending (drag operations bypass bot.clickWindow) let dragStateId = typeof (bot as any)._stateId === 'number' ? (bot as any)._stateId : -1 // True only while drag packets are being written; guards trackState against // stale server responses overwriting our predicted stateId mid-sequence. let isDraggingRaw = false const debugSession = createInventoryDebugSession( hotbarOnly ? 'mineflayer-connector:hotbar' : 'mineflayer-connector', () => { const state = buildWindowState() return { windowState: state, playerState: buildPlayerState(), heldItem: state?.heldItem ?? null, } }, ) debugSession.log({ event: 'connector.mount', data: { hotbarOnly, }, }) // Resolve the Item class (prismarine-item) for converting notch-format items. // We extract it lazily from the first non-null slot item's constructor. let ItemClass: { fromNotch(notch: unknown): unknown } | null = null function getItemClass(): typeof ItemClass { if (ItemClass) return ItemClass const win = bot.currentWindow ?? bot.inventory for (const slot of win.slots) { if (slot) { ItemClass = slot.constructor as any return ItemClass } } return null } const rawPacketListeners: Array<[string, (...args: any[]) => void]> = [] if (ext._client?.on) { const trackState = (packet: any) => { if (packet.stateId != null) { if (isDraggingRaw) { // During active drag, only accept higher stateIds to prevent // stale server responses from reverting our predicted value. if (packet.stateId > dragStateId) { dragStateId = packet.stateId } } else { dragStateId = packet.stateId } } } ext._client.on('window_items' as any, trackState) ext._client.on('set_slot' as any, trackState) rawPacketListeners.push(['window_items', trackState], ['set_slot', trackState]) // Mineflayer drops set_slot with windowId=-1 (cursor updates) because the window // lookup fails. Intercept these to keep selectedItem in sync after raw packet ops. const onRawSetSlot = (packet: any) => { if (packet.windowId !== -1 || packet.slot !== -1) return const win = bot.currentWindow ?? bot.inventory const IC = getItemClass() if (IC && packet.item) { ;(win as any).selectedItem = IC.fromNotch(packet.item) ?? null } else { ;(win as any).selectedItem = null } emit({ type: 'heldItemChange', item: convert((win as any).selectedItem) }) } ext._client.on('set_slot' as any, onRawSetSlot) rawPacketListeners.push(['set_slot', onRawSetSlot]) // Mineflayer processes window_items slots but ignores the carriedItem field. // Intercept to update selectedItem from the server's cursor state. // Always call scheduleSlotUpdate() so UI gets corrected state even when mineflayer's // handler fires first with a stale selectedItem (drag-flash fix). const onRawWindowItems = (packet: any) => { if (packet.carriedItem != null) { const win = packet.windowId === 0 ? bot.inventory : (bot.currentWindow ?? bot.inventory) const IC = getItemClass() if (IC) { ;(win as any).selectedItem = IC.fromNotch(packet.carriedItem) ?? null } else { ;(win as any).selectedItem = null } } scheduleSlotUpdate() } ext._client.on('window_items' as any, onRawWindowItems) rawPacketListeners.push(['window_items', onRawWindowItems]) } function emit(event: ConnectorEvent) { listeners.forEach((l) => l(event)) } /** Build a reverse map from dataSlot index → property name for the current window type. */ function getDataSlotMap(type: string): Record | null { const typeDef = getInventoryType(type) if (!typeDef?.properties) return null const map: Record = {} for (const [name, def] of Object.entries(typeDef.properties)) { map[def.dataSlot] = name } return map } /** * Builds a window state from the currently open window, OR from `bot.inventory` * when no container is open (exposing the player's own inventory as a synthetic * 'player' window with windowId = 0). */ function buildWindowState(): InventoryWindowState | null { // In hotbarOnly mode, always build a player-like state (never the container itself) const win = hotbarOnly ? null : bot.currentWindow if (win) { const title = formatTitle ? formatTitle(win.title) : win.title const state: InventoryWindowState = { windowId: win.id, type: win.type ?? 'unknown', title, slots: botSlotsToSlotStates(win.slots, convert), heldItem: convert(win.selectedItem), } if (Object.keys(windowProperties).length > 0) { state.properties = { ...windowProperties } } return state } // No open container (or hotbarOnly) — expose the player inventory. // When a container IS open in hotbarOnly mode, mineflayer doesn't update // bot.inventory.slots in real time — read player slots from the container // window instead (using inventoryStart offset). const containerWin = hotbarOnly ? bot.currentWindow : null const invStart: number | null = containerWin ? (containerWin as any).inventoryStart ?? null : null const readSlot = (playerSlotIndex: number) => { if (containerWin && invStart != null) { // Map player inventory index to container window index const containerIndex = playerSlotIndex - 9 + invStart return convert(containerWin.slots[containerIndex]) } return convert(bot.inventory.slots[playerSlotIndex]) } const invSlots: SlotState[] = [] // Slots 0–8: crafting result (0), crafting grid (1-4), armor (5-8) for (let i = 0; i < 9; i++) invSlots.push({ index: i, item: convert(bot.inventory.slots[i]) }) // Slots 9–35: main inventory for (let i = 9; i <= 35; i++) invSlots.push({ index: i, item: readSlot(i) }) // Slots 36–44: hotbar for (let i = 36; i <= 44; i++) invSlots.push({ index: i, item: readSlot(i) }) // Slot 45: offhand invSlots.push({ index: 45, item: convert(bot.inventory.slots[45]) }) return { windowId: 0, type: 'player', title: undefined, slots: invSlots, heldItem: convert((bot.inventory as any).selectedItem ?? null), } } function buildPlayerState(): PlayerState { const inv = bot.inventory.slots return { activeHotbarSlot: bot.quickBarSlot, inventory: botSlotsToSlotStates(inv, convert), } } function logActionEvent( event: string, action: InventoryAction, data?: Record, ) { const state = buildWindowState() const slotIndexes = getActionSlotIndexes(action) debugSession.log({ event, windowId: state?.windowId, windowType: state?.type, action: summarizeAction(action), stateId: dragStateId, slots: state ? summarizeSlots(state.slots, slotIndexes) : undefined, heldItem: summarizeItem(state?.heldItem), data: data ? sanitizeDebugValue(data) : summarizeWindowState(state, slotIndexes), }) } function logPacketWrite(packet: string, params: unknown) { const state = buildWindowState() const paramsObject = params && typeof params === 'object' ? params as Record : {} debugSession.log({ event: 'connector.packetWrite', windowId: typeof paramsObject.windowId === 'number' ? paramsObject.windowId : state?.windowId, windowType: state?.type, stateId: typeof paramsObject.stateId === 'number' ? paramsObject.stateId : dragStateId, data: { packet, params: sanitizeDebugValue(params), }, }) } function logHelperIntent(action: InventoryAction, helper: string, params: Record) { const state = buildWindowState() const slotIndexes = getActionSlotIndexes(action) debugSession.log({ event: 'connector.helperIntent', windowId: state?.windowId, windowType: state?.type, action: summarizeAction(action), stateId: dragStateId, slots: state ? summarizeSlots(state.slots, slotIndexes) : undefined, heldItem: summarizeItem(state?.heldItem), data: { helper, params: sanitizeDebugValue(params), }, }) } /** Compute anvil cost client-side if callback is provided and window is anvil. */ function tryComputeAnvilCost() { if (!computeAnvilCost || !currentWindowType) return const resolved = getInventoryType(currentWindowType) if (resolved?.name !== 'anvil') return const win = bot.currentWindow if (!win) return const item1 = (win.slots[0] as RawSlot | null) ?? null const item2 = (win.slots[1] as RawSlot | null) ?? null try { const cost = item1 ? computeAnvilCost(item1, item2) : null // Only SET client cost, never delete server-provided values. // The server's craft_progress_bar will send 0 to clear when appropriate. if (cost != null && cost > 0) { windowProperties.repairCost = cost } } catch (err) { // Client-side computation failed — rely on server cost } } const onWindowOpen = () => { const state = buildWindowState() if (state) emit({ type: 'windowOpen', state }) } const onWindowClose = () => { emit({ type: 'windowClose' }) } const onSetSlot = () => { tryComputeAnvilCost() const state = buildWindowState() if (state) emit({ type: 'windowUpdate', state }) emit({ type: 'playerUpdate', state: buildPlayerState() }) } const onHeldItemChanged = () => { emit({ type: 'playerUpdate', state: buildPlayerState() }) } // Mineflayer emits 'setSlot:${windowId}' (e.g. 'setSlot:0'), not plain 'setSlot'. // Always listen on window 0 (player inventory) and dynamically track container windows. // Also listen for 'heldItemChanged' to track active hotbar slot changes. let currentWindowSlotEvent: string | null = null let currentWindowItemsEvent: string | null = null const onWindowOpenInternal = () => { const win = bot.currentWindow if (win) { currentWindowSlotEvent = `setSlot:${win.id}` currentWindowItemsEvent = `setWindowItems:${win.id}` bot.on(currentWindowSlotEvent as any, onSetSlot) bot.on(currentWindowItemsEvent as any, scheduleSlotUpdate) // Reset properties and resolve window type for property mapping for (const key of Object.keys(windowProperties)) delete windowProperties[key] currentWindowType = win.type ?? null ;(win as any).on('updateSlot', scheduleSlotUpdate) } if (!hotbarOnly) onWindowOpen() } const onWindowCloseInternal = () => { const closingWin = bot.currentWindow if (closingWin) { ;(closingWin as any).off('updateSlot', scheduleSlotUpdate) } if (currentWindowSlotEvent) { bot.off(currentWindowSlotEvent as any, onSetSlot) currentWindowSlotEvent = null } if (currentWindowItemsEvent) { bot.off(currentWindowItemsEvent as any, scheduleSlotUpdate) currentWindowItemsEvent = null } currentWindowType = null for (const key of Object.keys(windowProperties)) delete windowProperties[key] if (!hotbarOnly) onWindowClose() // In hotbar mode, emit a windowUpdate after close so the hotbar // re-syncs from bot.inventory (now freshly copied back by mineflayer) if (hotbarOnly) scheduleSlotUpdate() } // Handle craft_progress_bar packets (furnace progress, enchant levels, etc.) const onCraftProgressBar = (packet: { windowId: number; property: number; value: number }) => { const win = bot.currentWindow if (!win || packet.windowId !== win.id || !currentWindowType) return const slotMap = getDataSlotMap(currentWindowType) if (!slotMap) return const propName = slotMap[packet.property] if (propName) { windowProperties[propName] = packet.value const state = buildWindowState() if (state) emit({ type: 'windowUpdate', state }) } } // Listen to Window-level slot changes to catch optimistic acceptClick updates // that don't fire bot-level events (needed for multi-connector architecture) let slotUpdateScheduled = false const scheduleSlotUpdate = () => { if (slotUpdateScheduled) return slotUpdateScheduled = true queueMicrotask(() => { slotUpdateScheduled = false onSetSlot() }) } ;(bot.inventory as any).on('updateSlot', scheduleSlotUpdate) bot.on('windowOpen', onWindowOpenInternal) bot.on('windowClose', onWindowCloseInternal) bot.on('setSlot:0' as any, onSetSlot) bot.on('setWindowItems:0' as any, scheduleSlotUpdate) bot.on('heldItemChanged' as any, onHeldItemChanged) if (!hotbarOnly && ext._client) { ext._client.on?.('craft_progress_bar' as any, onCraftProgressBar as any) } // If a window is already open when the connector is created (common case: // windowOpen event fires → client shows modal → React mounts → connector created), // perform the same setup that onWindowOpenInternal would have done so that // dynamic slot listeners and currentWindowType are initialised properly. // Note: any craft_progress_bar packets received before this point are lost, // but the server resends them continuously so the gap is imperceptible. if (!hotbarOnly && bot.currentWindow) { onWindowOpenInternal() } async function openPlayerInventory() { const vehicle = bot.vehicle let inventoryType = 'player' if (vehicle) { const entityName = String(vehicle.name ?? vehicle.entityType ?? '').toLowerCase() // Check if riding a llama (includes trader_llama) if (/llama/i.test(entityName)) { inventoryType = 'llama' } } // Build a window state for the player/llama inventory const slots: SlotState[] = [] if (inventoryType === 'llama') { // Llama inventory structure (per registry): // Slot 0: Carpet (saddle) - empty since we don't have entity data slots.push({ index: 0, item: null }) // Slots 2-16: Llama chest (5×3 grid = 15 slots) - empty since we don't have entity data for (let i = 2; i <= 16; i++) { slots.push({ index: i, item: null }) } // Slots 17-43: Player inventory (bot.inventory.slots indices 9-35 map to window slots 17-43) for (let i = 9; i <= 35; i++) { slots.push({ index: i + 8, item: convert(bot.inventory.slots[i]) }) } // Slots 44-52: Hotbar (bot.inventory.slots indices 36-44 map to window slots 44-52) for (let i = 36; i <= 44; i++) { slots.push({ index: i + 8, item: convert(bot.inventory.slots[i]) }) } // Slot 53: Offhand (bot.inventory slot 45 maps to window slot 53) slots.push({ index: 53, item: convert(bot.inventory.slots[45]) }) } else { // Player inventory window structure (per registry): // Slots 0-8: crafting result (0), crafting grid (1-4), armor (5-8) for (let i = 0; i < 9; i++) { slots.push({ index: i, item: convert(bot.inventory.slots[i]) }) } // Slots 9-35: Player inventory (bot.inventory.slots indices 9-35) for (let i = 9; i <= 35; i++) { slots.push({ index: i, item: convert(bot.inventory.slots[i]) }) } // Slots 36-44: Hotbar (bot.inventory.slots indices 36-44) for (let i = 36; i <= 44; i++) { slots.push({ index: i, item: convert(bot.inventory.slots[i]) }) } // Slot 45: Offhand (bot.inventory slot 45) slots.push({ index: 45, item: convert(bot.inventory.slots[45]) }) } const windowState: InventoryWindowState = { windowId: -1, // Use -1 to indicate a synthetic/UI-only window type: inventoryType, title: inventoryType === 'llama' ? 'Llama' : undefined, slots, heldItem: convert(bot.heldItem), } emit({ type: 'windowOpen', state: windowState }) } return { getWindowState: buildWindowState, getPlayerState: buildPlayerState, openPlayerInventory, sendAction: async (action: InventoryAction) => { logActionEvent('connector.action.start', action) try { // Hotbar "open inventory" button — delegates to openPlayerInventory() if (action.type === 'open-inventory') { await openPlayerInventory() logActionEvent('connector.action.success', action) return } if (action.type === 'hotbar-select') { if (!hotbarOnly) { logActionEvent('connector.action.skipped', action, { reason: 'not_hotbar_only' }) return } if (action.slotIndex < 36 || action.slotIndex > 44) { logActionEvent('connector.action.skipped', action, { reason: 'invalid_hotbar_slot' }) return } const extBot = bot as MineflayerBot & { setQuickBarSlot?: (i: number) => void } extBot.setQuickBarSlot?.(action.slotIndex - 36) onSetSlot() logActionEvent('connector.action.success', action) return } const win = bot.currentWindow if (action.type === 'trade' && win) { let handled = false if (ext.trade && isVillagerWindow(win)) { logHelperIntent(action, 'bot.trade', { tradeIndex: action.tradeIndex, count: 1 }) await ext.trade(win, action.tradeIndex, 1) handled = true } else if (isVillagerWindow(win)) { logHelperIntent(action, 'window.trade', { tradeIndex: action.tradeIndex, count: 1 }) await win.trade(action.tradeIndex, 1) handled = true } if (handled) { logActionEvent('connector.action.success', action) } else { logActionEvent('connector.action.skipped', action, { reason: 'missing_trade_handler' }) } return } if (action.type === 'enchant' && win && isEnchantmentTableWindow(win)) { logHelperIntent(action, 'window.enchant', { enchantIndex: action.enchantIndex }) await win.enchant(action.enchantIndex) logActionEvent('connector.action.success', action) return } if (action.type === 'rename' && win && isAnvilWindow(win) && ext._client) { // Send name_item packet directly — don't use mineflayer's win.rename() // because it tries to transfer items from player inventory into anvil, // which fails when the user already placed items via the UI. if (ext.supportFeature?.('useMCItemName')) { if (!ext._client.registerChannel) { logActionEvent('connector.action.skipped', action, { reason: 'missing_registerChannel' }) return } ext._client.registerChannel('MC|ItemName', 'string') logPacketWrite('MC|ItemName', action.text) ext._client.writeChannel?.('MC|ItemName', action.text) } else { const packet = { name: action.text } logPacketWrite('name_item', packet) ext._client.write('name_item', packet) } logActionEvent('connector.action.success', action) return } if (action.type === 'beacon' && win && isBeaconWindow(win)) { // EFFECTS values are 1-based (matching vanilla encodeEffect: registry_id + 1) // Protocol option(varint): undefined=absent, varint=1-based effect ID // Server ContainerData uses same encoding: ≤0 = none, 1+ = effect const protoPrimary = action.primaryEffect > 0 ? action.primaryEffect : undefined const protoSecondary = action.secondaryEffect > 0 ? action.secondaryEffect : undefined let handled = false if (typeof win.setBeaconEffects === 'function') { logHelperIntent(action, 'window.setBeaconEffects', { primaryEffect: protoPrimary, secondaryEffect: protoSecondary, }) await win.setBeaconEffects(protoPrimary as any, protoSecondary as any) handled = true } else if (ext._client) { const packet = { primary_effect: protoPrimary, secondary_effect: protoSecondary, } logPacketWrite('set_beacon_effect', packet) ext._client.write('set_beacon_effect', packet) handled = true } if (handled) { logActionEvent('connector.action.success', action) } else { logActionEvent('connector.action.skipped', action, { reason: 'missing_beacon_handler' }) } return } if (action.type === 'click' && action.mode === 'double') { // bot.clickWindow() throws for mode=6 (prismarine-windows doubleClick is unimplemented). // Send raw window_click packet directly, same approach as drag (mode=5). if (!ext._client) { logActionEvent('connector.action.skipped', action, { reason: 'missing_client' }) return } const windowId = bot.currentWindow ? bot.currentWindow.id : 0 const packet = { windowId, stateId: dragStateId, slot: action.slotIndex, mouseButton: 0, mode: 6, changedSlots: [], cursorItem: { present: false } as any, } logPacketWrite('window_click', packet) ext._client.write('window_click', packet) dragStateId++ logActionEvent('connector.action.success', action) return } if (action.type === 'click') { const [mouseButton, mode] = modeFromAction(action) logHelperIntent(action, 'bot.clickWindow', { slot: action.slotIndex, mouseButton, mode, }) await bot.clickWindow(action.slotIndex, mouseButton, mode) onSetSlot() logActionEvent('connector.action.success', action) } else if (action.type === 'drag') { // bot.clickWindow() throws for mode=5 (prismarine-windows dragClick is unimplemented). // Send raw window_click packets directly via _client.write. if (!ext._client) { logActionEvent('connector.action.skipped', action, { reason: 'missing_client' }) return } const isRight = action.button === 'right' const startButton = isRight ? 4 : 0 const slotButton = isRight ? 5 : 1 const endButton = isRight ? 6 : 2 const windowId = bot.currentWindow ? bot.currentWindow.id : 0 const cursorItem = { present: false } as any isDraggingRaw = true try { const writeClick = (slot: number, mouseButton: number) => { const packet = { windowId, stateId: dragStateId, slot, mouseButton, mode: 5, changedSlots: [], cursorItem, } logPacketWrite('window_click', packet) ext._client!.write('window_click', packet) dragStateId++ } writeClick(-999, startButton) for (const slot of action.slots) { writeClick(slot, slotButton) } writeClick(-999, endButton) } finally { isDraggingRaw = false } logActionEvent('connector.action.success', action) } else if (action.type === 'drop') { if (action.slotIndex === -1) { // Drop cursor item by clicking outside the window: slot=-999, mode=0 // Left click (all=true) drops entire stack, right click (all=false) drops one if (!ext._client) { logActionEvent('connector.action.skipped', action, { reason: 'missing_client' }) return } const windowId = bot.currentWindow ? bot.currentWindow.id : 0 const mouseButton = action.all ? 0 : 1 const packet = { windowId, stateId: dragStateId, slot: -999, mouseButton, mode: 0, changedSlots: [], cursorItem: { present: false } as any, } logPacketWrite('window_click', packet) ext._client.write('window_click', packet) dragStateId++ // Skip onSetSlot() — mineflayer's selectedItem is not updated yet. // The server will send set_slot/window_items to sync the cursor state. } else { // Drop from specific slot via Q key: mode=4 logHelperIntent(action, 'bot.clickWindow', { slot: action.slotIndex, mouseButton: action.all ? 1 : 0, mode: 4, }) await bot.clickWindow(action.slotIndex, action.all ? 1 : 0, 4) onSetSlot() } logActionEvent('connector.action.success', action) } else if (action.type === 'close') { if (win) { logHelperIntent(action, 'bot.closeWindow', { windowId: win.id }) bot.closeWindow(win) } else { // Player inventory (synthetic) — send close_window so server drops cursor items if (ext._client) { const packet = { windowId: 0 } logPacketWrite('close_window', packet) ext._client.write('close_window', packet) } ;(bot.inventory as any).selectedItem = null } logActionEvent('connector.action.success', action) } else if (action.type === 'hotbar-swap') { logHelperIntent(action, 'bot.clickWindow', { slot: action.slotIndex, mouseButton: action.hotbarSlot, mode: 2, }) await bot.clickWindow(action.slotIndex, action.hotbarSlot, 2) onSetSlot() logActionEvent('connector.action.success', action) } else { logActionEvent('connector.action.ignored', action, { reason: 'no_matching_handler' }) } } catch (err) { logActionEvent('connector.action.failure', action, { error: err instanceof Error ? err.message : String(err) }) const detail = 'slotIndex' in action ? ` slot=${(action as any).slotIndex}` : '' console.error(`[minecraft-inventory] sendAction "${action.type}"${detail} failed:`, err) } }, closeWindow: () => { const win = bot.currentWindow if (win) bot.closeWindow(win) }, subscribe: (listener: ConnectorListener) => { listeners.add(listener) return () => { listeners.delete(listener) bot.off('windowOpen', onWindowOpenInternal) bot.off('windowClose', onWindowCloseInternal) bot.off('setSlot:0' as any, onSetSlot) bot.off('setWindowItems:0' as any, scheduleSlotUpdate) bot.off('heldItemChanged' as any, onHeldItemChanged) if (ext._client?.off) { if (!hotbarOnly) { ext._client.off('craft_progress_bar' as any, onCraftProgressBar as any) } for (const [event, listener] of rawPacketListeners) { ext._client.off(event as any, listener as any) } } if (currentWindowSlotEvent) { bot.off(currentWindowSlotEvent as any, onSetSlot) } if (currentWindowItemsEvent) { bot.off(currentWindowItemsEvent as any, scheduleSlotUpdate) } ;(bot.inventory as any).off('updateSlot', scheduleSlotUpdate) if (bot.currentWindow) { ;(bot.currentWindow as any).off('updateSlot', scheduleSlotUpdate) } debugSession.log({ event: 'connector.unmount' }) debugSession.dispose() } }, } }