minecraft-inventory 0.1.5 → 0.1.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -96,6 +96,7 @@ export function JEI({
96
96
 
97
97
  const ro = new ResizeObserver((entries) => {
98
98
  for (const entry of entries) {
99
+ // console.log('got size', entry.target.className, entry.contentRect.width, entry.contentRect.height)
99
100
  if (entry.target === (root as unknown as Element)) {
100
101
  sizes.rootW = entry.contentRect.width
101
102
  } else if (entry.target === (grid as unknown as Element)) {
@@ -240,8 +241,6 @@ export function JEI({
240
241
  className="mc-inv-jei-header"
241
242
  style={{
242
243
  padding: `${padding}px`,
243
- background: '#c6c6c6',
244
- // border: `${scale}px solid #555555`,
245
244
  flexShrink: 0,
246
245
  }}
247
246
  >
@@ -284,7 +283,7 @@ export function JEI({
284
283
  >
285
284
 
286
285
  </button>
287
- <span className="mc-inv-jei-page-counter" style={{ flex: 1, textAlign: 'center', color: '#404040' }}>
286
+ <span className="mc-inv-jei-page-counter" style={{ flex: 1, textAlign: 'center', color: '#ffffff' }}>
288
287
  {page + 1} / {Math.max(1, totalPages)}
289
288
  </span>
290
289
  <button
@@ -46,6 +46,7 @@ export function Slot({
46
46
  startDrag,
47
47
  addDragSlot,
48
48
  endDrag,
49
+ cancelDrag,
49
50
  hoveredSlot,
50
51
  setHoveredSlot,
51
52
  activeNumberKey,
@@ -53,12 +54,14 @@ export function Slot({
53
54
  setPKeyActive,
54
55
  focusedSlot,
55
56
  setFocusedSlot,
57
+ dragEndedRef,
56
58
  } = useInventoryContext()
57
59
 
58
60
  const { contentSize } = useScale()
59
61
  const isMobile = useMobile()
60
62
  const slotRef = useRef<HTMLDivElement>(null)
61
63
  const labelRef = useRef<HTMLDivElement>(null)
64
+ const lastClickTimeRef = useRef(0)
62
65
  const [mobileTouchPos, setMobileTouchPos] = useState({ x: 0, y: 0 })
63
66
  const [showTooltip, setShowTooltip] = useState(false)
64
67
  const [mobileMenuOpen, setMobileMenuOpen] = useState(false)
@@ -122,16 +125,19 @@ export function Slot({
122
125
  (e: React.MouseEvent) => {
123
126
  if (isMobile || disabled) return
124
127
  e.preventDefault()
128
+ dragEndedRef.current = false
125
129
  const button = e.button === 2 ? 'right' : e.button === 1 ? 'middle' : 'left'
126
130
  if (button === 'middle') {
127
131
  sendAction({ type: 'click', slotIndex: index, button: 'middle', mode: 'middle' })
128
132
  return
129
133
  }
130
134
  if (heldItem && (button === 'left' || button === 'right')) {
135
+ // Don't start drag during double-click sequence
136
+ if (Date.now() - lastClickTimeRef.current < 400) return
131
137
  startDrag(index, button)
132
138
  }
133
139
  },
134
- [isMobile, disabled, heldItem, index, sendAction, startDrag],
140
+ [isMobile, disabled, heldItem, index, sendAction, startDrag, dragEndedRef],
135
141
  )
136
142
 
137
143
  const handleMouseUp = useCallback(
@@ -145,6 +151,11 @@ export function Slot({
145
151
  return
146
152
  }
147
153
 
154
+ // Suppress spurious mouseUp events that fire after a drag ends.
155
+ // The browser can dispatch extra mouseUp events after endDrag resets isDragging;
156
+ // without this guard they fall through to the click path below.
157
+ if (dragEndedRef.current) return
158
+
148
159
  // Focus/swap logic — active in P mode OR when a slot is already focused
149
160
  if (button === 'left' && (pKeyActive || focusedSlot !== null)) {
150
161
  if (pKeyActive) setPKeyActive(false)
@@ -162,6 +173,15 @@ export function Slot({
162
173
  return
163
174
  }
164
175
 
176
+ // Suppress the second mouseup of a double-click to prevent it from
177
+ // putting the item back before the dblclick event fires mode=6.
178
+ const now = Date.now()
179
+ if (button === 'left' && now - lastClickTimeRef.current < 400) {
180
+ lastClickTimeRef.current = 0
181
+ return
182
+ }
183
+ lastClickTimeRef.current = now
184
+
165
185
  const mode = e.shiftKey ? 'shift' : 'normal'
166
186
  if (onClickOverride) {
167
187
  onClickOverride(button, mode)
@@ -175,20 +195,21 @@ export function Slot({
175
195
  }
176
196
  if (isDragging) endDrag()
177
197
  },
178
- [isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride, resultSlot, heldItem, item, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot],
198
+ [isMobile, disabled, isDragging, dragSlots.length, sendAction, index, endDrag, onClickOverride, resultSlot, heldItem, item, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot, dragEndedRef],
179
199
  )
180
200
 
181
201
  const handleDoubleClick = useCallback(
182
202
  (e: React.MouseEvent) => {
183
203
  if (isMobile || disabled) return
184
204
  e.preventDefault()
205
+ cancelDrag()
185
206
  if (onClickOverride) {
186
207
  onClickOverride('left', 'double')
187
208
  } else {
188
209
  sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'double' })
189
210
  }
190
211
  },
191
- [isMobile, disabled, sendAction, index, onClickOverride],
212
+ [isMobile, disabled, sendAction, index, onClickOverride, cancelDrag],
192
213
  )
193
214
 
194
215
  const handleContextMenu = useCallback((e: React.MouseEvent) => {
@@ -16,6 +16,7 @@ const CODE_COLORS: Record<string, string> = {
16
16
  function parseSectionCodes(text: string): MessageFormatPart[] {
17
17
  const parts: MessageFormatPart[] = []
18
18
  const regex = /§([0-9a-fk-orA-FK-OR])|([^§]+)/g
19
+ regex.lastIndex = 0
19
20
  let color: string | undefined
20
21
  let bold = false, italic = false, underlined = false
21
22
  let strikethrough = false, obfuscated = false
@@ -85,8 +85,7 @@ export function Tooltip({ item, visible }: TooltipProps) {
85
85
  fontSize: fs,
86
86
  padding: pad,
87
87
  gap: gap2,
88
- minWidth: Math.round(80 * scale),
89
- maxWidth: Math.round(220 * scale),
88
+ width: 'max-content',
90
89
  // Start invisible; applyPosition sets visibility after measuring dimensions
91
90
  visibility: 'hidden',
92
91
  pointerEvents: 'none',
@@ -1,5 +1,6 @@
1
1
  import type { InventoryAction, InventoryWindowState, PlayerState, SlotState, ItemStack } from '../types'
2
2
  import type { InventoryConnector, ConnectorListener, ConnectorEvent, MineflayerBot } from './types'
3
+ import { getInventoryType } from '../registry'
3
4
 
4
5
  type RawSlot = { type: number; count: number; metadata?: number; nbt?: unknown }
5
6
 
@@ -10,21 +11,47 @@ export interface MineflayerConnectorOptions {
10
11
  /**
11
12
  * Custom item mapper called for every slot conversion from raw mineflayer data to
12
13
  * {@link ItemStack}. Receives the raw slot data and the default-mapped stack.
13
- * Return a modified stack to override fields (e.g. `name`, `textureKey`, `displayName`),
14
- * or return the second argument unchanged to use the default mapping.
14
+ * Return a modified stack to override fields (e.g. `name`, `textureKey`, `displayName`,
15
+ * `texture`, `blockTexture`), or return the second argument unchanged to use the default mapping.
15
16
  *
16
17
  * @example
17
18
  * ```ts
18
19
  * createMineflayerConnector(bot, {
19
20
  * itemMapper: (raw, mapped) => ({
20
21
  * ...mapped,
21
- * // Override texture for specific numeric type IDs:
22
22
  * textureKey: raw.type === 438 ? 'item/potion_water' : mapped.textureKey,
23
23
  * }),
24
24
  * })
25
25
  * ```
26
+ *
27
+ * @example Block texture with isometric face slices
28
+ * ```ts
29
+ * itemMapper: (raw, mapped) => ({
30
+ * ...mapped,
31
+ * blockTexture: {
32
+ * source: blockAtlasUrl,
33
+ * top: { slice: [0, 0, 16, 16] },
34
+ * left: { slice: [16, 0, 16, 16] },
35
+ * right: { slice: [32, 0, 16, 16] },
36
+ * },
37
+ * })
38
+ * ```
26
39
  */
27
40
  itemMapper?: (raw: RawSlot, mapped: ItemStack) => ItemStack
41
+
42
+ /**
43
+ * When true, the connector only tracks the player inventory (window 0) and never
44
+ * emits windowOpen/windowUpdate/windowClose events. Use for HUD hotbar that must
45
+ * always show player hotbar slots regardless of open container windows.
46
+ */
47
+ hotbarOnly?: boolean
48
+
49
+ /**
50
+ * Optional title formatter. Called with the raw window title from the server
51
+ * (may be a JSON text component string, NBT object, or plain text).
52
+ * Return a human-readable string for display.
53
+ */
54
+ formatTitle?: (rawTitle: any) => string
28
55
  }
29
56
 
30
57
  function makeSlotConverter(itemMapper?: MineflayerConnectorOptions['itemMapper']) {
@@ -70,6 +97,9 @@ interface MineflayerBotExtended extends MineflayerBot {
70
97
  supportFeature?(name: string): boolean
71
98
  _client?: {
72
99
  write(packet: string, data: unknown): void
100
+ on?(event: string, listener: (...args: any[]) => void): void
101
+ off?(event: string, listener: (...args: any[]) => void): void
102
+ removeListener?(event: string, listener: (...args: any[]) => void): void
73
103
  writeChannel?(channel: string, data: unknown): void
74
104
  registerChannel?(channel: string, schema: unknown): void
75
105
  }
@@ -106,35 +136,148 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
106
136
  const listeners = new Set<ConnectorListener>()
107
137
  const ext = bot as MineflayerBotExtended
108
138
  const convert = makeSlotConverter(options?.itemMapper)
139
+ const hotbarOnly = options?.hotbarOnly ?? false
140
+ const formatTitle = options?.formatTitle
141
+
142
+ // Track window properties (furnace progress, enchant levels, etc.)
143
+ const windowProperties: Record<string, number> = {}
144
+ let currentWindowType: string | null = null
145
+
146
+ // Track stateId for raw packet sending (drag operations bypass bot.clickWindow)
147
+ let dragStateId = typeof (bot as any)._stateId === 'number' ? (bot as any)._stateId : -1
148
+ // True only while drag packets are being written; guards trackState against
149
+ // stale server responses overwriting our predicted stateId mid-sequence.
150
+ let isDraggingRaw = false
151
+
152
+ // Resolve the Item class (prismarine-item) for converting notch-format items.
153
+ // We extract it lazily from the first non-null slot item's constructor.
154
+ let ItemClass: { fromNotch(notch: unknown): unknown } | null = null
155
+ function getItemClass(): typeof ItemClass {
156
+ if (ItemClass) return ItemClass
157
+ const win = bot.currentWindow ?? bot.inventory
158
+ for (const slot of win.slots) {
159
+ if (slot) {
160
+ ItemClass = slot.constructor as any
161
+ return ItemClass
162
+ }
163
+ }
164
+ return null
165
+ }
166
+
167
+ const rawPacketListeners: Array<[string, (...args: any[]) => void]> = []
168
+ if (ext._client?.on) {
169
+ const trackState = (packet: any) => {
170
+ if (packet.stateId != null) {
171
+ if (isDraggingRaw) {
172
+ // During active drag, only accept higher stateIds to prevent
173
+ // stale server responses from reverting our predicted value.
174
+ if (packet.stateId > dragStateId) {
175
+ dragStateId = packet.stateId
176
+ }
177
+ } else {
178
+ dragStateId = packet.stateId
179
+ }
180
+ }
181
+ }
182
+ ext._client.on('window_items' as any, trackState)
183
+ ext._client.on('set_slot' as any, trackState)
184
+ rawPacketListeners.push(['window_items', trackState], ['set_slot', trackState])
185
+
186
+ // Mineflayer drops set_slot with windowId=-1 (cursor updates) because the window
187
+ // lookup fails. Intercept these to keep selectedItem in sync after raw packet ops.
188
+ const onRawSetSlot = (packet: any) => {
189
+ if (packet.windowId !== -1 || packet.slot !== -1) return
190
+ const win = bot.currentWindow ?? bot.inventory
191
+ const IC = getItemClass()
192
+ if (IC && packet.item) {
193
+ ;(win as any).selectedItem = IC.fromNotch(packet.item) ?? null
194
+ } else {
195
+ ;(win as any).selectedItem = null
196
+ }
197
+ emit({ type: 'heldItemChange', item: convert((win as any).selectedItem) })
198
+ }
199
+ ext._client.on('set_slot' as any, onRawSetSlot)
200
+ rawPacketListeners.push(['set_slot', onRawSetSlot])
201
+
202
+ // Mineflayer processes window_items slots but ignores the carriedItem field.
203
+ // Intercept to update selectedItem from the server's cursor state.
204
+ // Always call scheduleSlotUpdate() so UI gets corrected state even when mineflayer's
205
+ // handler fires first with a stale selectedItem (drag-flash fix).
206
+ const onRawWindowItems = (packet: any) => {
207
+ if (packet.carriedItem != null) {
208
+ const win = packet.windowId === 0 ? bot.inventory : (bot.currentWindow ?? bot.inventory)
209
+ const IC = getItemClass()
210
+ if (IC) {
211
+ ;(win as any).selectedItem = IC.fromNotch(packet.carriedItem) ?? null
212
+ } else {
213
+ ;(win as any).selectedItem = null
214
+ }
215
+ }
216
+ scheduleSlotUpdate()
217
+ }
218
+ ext._client.on('window_items' as any, onRawWindowItems)
219
+ rawPacketListeners.push(['window_items', onRawWindowItems])
220
+ }
109
221
 
110
222
  function emit(event: ConnectorEvent) {
111
223
  listeners.forEach((l) => l(event))
112
224
  }
113
225
 
226
+ /** Build a reverse map from dataSlot index → property name for the current window type. */
227
+ function getDataSlotMap(type: string): Record<number, string> | null {
228
+ const typeDef = getInventoryType(type)
229
+ if (!typeDef?.properties) return null
230
+ const map: Record<number, string> = {}
231
+ for (const [name, def] of Object.entries(typeDef.properties)) {
232
+ map[def.dataSlot] = name
233
+ }
234
+ return map
235
+ }
236
+
114
237
  /**
115
238
  * Builds a window state from the currently open window, OR from `bot.inventory`
116
239
  * when no container is open (exposing the player's own inventory as a synthetic
117
240
  * 'player' window with windowId = 0).
118
241
  */
119
242
  function buildWindowState(): InventoryWindowState | null {
120
- const win = bot.currentWindow
243
+ // In hotbarOnly mode, always build a player-like state (never the container itself)
244
+ const win = hotbarOnly ? null : bot.currentWindow
121
245
  if (win) {
122
- return {
246
+ const title = formatTitle ? formatTitle(win.title) : win.title
247
+ const state: InventoryWindowState = {
123
248
  windowId: win.id,
124
249
  type: win.type ?? 'unknown',
125
- title: win.title,
250
+ title,
126
251
  slots: botSlotsToSlotStates(win.slots, convert),
127
- heldItem: convert(bot.heldItem),
252
+ heldItem: convert(win.selectedItem),
253
+ }
254
+ if (Object.keys(windowProperties).length > 0) {
255
+ state.properties = { ...windowProperties }
256
+ }
257
+ return state
258
+ }
259
+ // No open container (or hotbarOnly) — expose the player inventory.
260
+ // When a container IS open in hotbarOnly mode, mineflayer doesn't update
261
+ // bot.inventory.slots in real time — read player slots from the container
262
+ // window instead (using inventoryStart offset).
263
+ const containerWin = hotbarOnly ? bot.currentWindow : null
264
+ const invStart: number | null = containerWin ? (containerWin as any).inventoryStart ?? null : null
265
+ const readSlot = (playerSlotIndex: number) => {
266
+ if (containerWin && invStart != null) {
267
+ // Map player inventory index to container window index
268
+ const containerIndex = playerSlotIndex - 9 + invStart
269
+ return convert(containerWin.slots[containerIndex])
128
270
  }
271
+ return convert(bot.inventory.slots[playerSlotIndex])
129
272
  }
130
- // No open container — expose the player inventory as a synthetic 'player' window.
273
+
131
274
  const invSlots: SlotState[] = []
132
275
  // Slots 0–8: crafting/armour — leave empty (not accessible from bot.inventory directly)
133
276
  for (let i = 0; i < 9; i++) invSlots.push({ index: i, item: null })
134
277
  // Slots 9–35: main inventory
135
- for (let i = 9; i <= 35; i++) invSlots.push({ index: i, item: convert(bot.inventory.slots[i]) })
278
+ for (let i = 9; i <= 35; i++) invSlots.push({ index: i, item: readSlot(i) })
136
279
  // Slots 36–44: hotbar
137
- for (let i = 36; i <= 44; i++) invSlots.push({ index: i, item: convert(bot.inventory.slots[i]) })
280
+ for (let i = 36; i <= 44; i++) invSlots.push({ index: i, item: readSlot(i) })
138
281
  // Slot 45: offhand
139
282
  invSlots.push({ index: 45, item: convert(bot.inventory.slots[45]) })
140
283
  return {
@@ -142,7 +285,7 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
142
285
  type: 'player',
143
286
  title: undefined,
144
287
  slots: invSlots,
145
- heldItem: convert(bot.heldItem),
288
+ heldItem: convert((bot.inventory as any).selectedItem ?? null),
146
289
  }
147
290
  }
148
291
 
@@ -166,14 +309,91 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
166
309
  const onSetSlot = () => {
167
310
  const state = buildWindowState()
168
311
  if (state) emit({ type: 'windowUpdate', state })
169
- else {
170
- emit({ type: 'playerUpdate', state: buildPlayerState() })
312
+ emit({ type: 'playerUpdate', state: buildPlayerState() })
313
+ }
314
+
315
+ const onHeldItemChanged = () => {
316
+ emit({ type: 'playerUpdate', state: buildPlayerState() })
317
+ }
318
+
319
+ // Mineflayer emits 'setSlot:${windowId}' (e.g. 'setSlot:0'), not plain 'setSlot'.
320
+ // Always listen on window 0 (player inventory) and dynamically track container windows.
321
+ // Also listen for 'heldItemChanged' to track active hotbar slot changes.
322
+ let currentWindowSlotEvent: string | null = null
323
+ let currentWindowItemsEvent: string | null = null
324
+
325
+ const onWindowOpenInternal = () => {
326
+ const win = bot.currentWindow
327
+ if (win) {
328
+ currentWindowSlotEvent = `setSlot:${win.id}`
329
+ currentWindowItemsEvent = `setWindowItems:${win.id}`
330
+ bot.on(currentWindowSlotEvent as any, onSetSlot)
331
+ bot.on(currentWindowItemsEvent as any, scheduleSlotUpdate)
332
+ // Reset properties and resolve window type for property mapping
333
+ for (const key of Object.keys(windowProperties)) delete windowProperties[key]
334
+ currentWindowType = win.type ?? null
335
+ ;(win as any).on('updateSlot', scheduleSlotUpdate)
171
336
  }
337
+ if (!hotbarOnly) onWindowOpen()
172
338
  }
173
339
 
174
- bot.on('windowOpen', onWindowOpen)
175
- bot.on('windowClose', onWindowClose)
176
- bot.on('setSlot', onSetSlot)
340
+ const onWindowCloseInternal = () => {
341
+ const closingWin = bot.currentWindow
342
+ if (closingWin) {
343
+ ;(closingWin as any).off('updateSlot', scheduleSlotUpdate)
344
+ }
345
+ if (currentWindowSlotEvent) {
346
+ bot.off(currentWindowSlotEvent as any, onSetSlot)
347
+ currentWindowSlotEvent = null
348
+ }
349
+ if (currentWindowItemsEvent) {
350
+ bot.off(currentWindowItemsEvent as any, scheduleSlotUpdate)
351
+ currentWindowItemsEvent = null
352
+ }
353
+ currentWindowType = null
354
+ for (const key of Object.keys(windowProperties)) delete windowProperties[key]
355
+ if (!hotbarOnly) onWindowClose()
356
+ // In hotbar mode, emit a windowUpdate after close so the hotbar
357
+ // re-syncs from bot.inventory (now freshly copied back by mineflayer)
358
+ if (hotbarOnly) scheduleSlotUpdate()
359
+ }
360
+
361
+ // Handle craft_progress_bar packets (furnace progress, enchant levels, etc.)
362
+ const onCraftProgressBar = (packet: { windowId: number; property: number; value: number }) => {
363
+ const win = bot.currentWindow
364
+ if (!win || packet.windowId !== win.id || !currentWindowType) return
365
+ const slotMap = getDataSlotMap(currentWindowType)
366
+ if (!slotMap) return
367
+ const propName = slotMap[packet.property]
368
+ if (propName) {
369
+ windowProperties[propName] = packet.value
370
+ const state = buildWindowState()
371
+ if (state) emit({ type: 'windowUpdate', state })
372
+ }
373
+ }
374
+
375
+ // Listen to Window-level slot changes to catch optimistic acceptClick updates
376
+ // that don't fire bot-level events (needed for multi-connector architecture)
377
+ let slotUpdateScheduled = false
378
+ const scheduleSlotUpdate = () => {
379
+ if (slotUpdateScheduled) return
380
+ slotUpdateScheduled = true
381
+ queueMicrotask(() => {
382
+ slotUpdateScheduled = false
383
+ onSetSlot()
384
+ })
385
+ }
386
+
387
+ ;(bot.inventory as any).on('updateSlot', scheduleSlotUpdate)
388
+
389
+ bot.on('windowOpen', onWindowOpenInternal)
390
+ bot.on('windowClose', onWindowCloseInternal)
391
+ bot.on('setSlot:0' as any, onSetSlot)
392
+ bot.on('setWindowItems:0' as any, scheduleSlotUpdate)
393
+ bot.on('heldItemChanged' as any, onHeldItemChanged)
394
+ if (!hotbarOnly && ext._client) {
395
+ ext._client.on?.('craft_progress_bar' as any, onCraftProgressBar as any)
396
+ }
177
397
 
178
398
  async function openPlayerInventory() {
179
399
  const vehicle = bot.vehicle
@@ -243,57 +463,123 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
243
463
  openPlayerInventory,
244
464
 
245
465
  sendAction: async (action: InventoryAction) => {
246
- // Hotbar "open inventory" button — delegates to openPlayerInventory()
247
- if (action.type === 'open-inventory') {
248
- await openPlayerInventory()
249
- return
250
- }
251
-
252
- const win = bot.currentWindow
253
-
254
- if (action.type === 'trade' && win) {
255
- if (ext.trade && isVillagerWindow(win)) {
256
- await ext.trade(win, action.tradeIndex, 1)
257
- } else if (isVillagerWindow(win)) {
258
- await win.trade(action.tradeIndex, 1)
466
+ try {
467
+ // Hotbar "open inventory" button — delegates to openPlayerInventory()
468
+ if (action.type === 'open-inventory') {
469
+ await openPlayerInventory()
470
+ return
259
471
  }
260
- return
261
- }
262
-
263
- if (action.type === 'enchant' && win && isEnchantmentTableWindow(win)) {
264
- await win.enchant(action.enchantIndex)
265
- return
266
- }
267
-
268
- if (action.type === 'rename' && win && isAnvilWindow(win)) {
269
- const w = win as { slots?: unknown[]; findInventoryItem?: (id: number) => unknown; rename: (item: unknown, name: string) => Promise<void> }
270
- const inputSlot = w.slots?.[0] as { type?: number; metadata?: number; count?: number; nbt?: unknown } | null
271
- const item = inputSlot?.type ? (w.findInventoryItem?.(inputSlot.type) ?? inputSlot) : null
272
- if (item) await w.rename(item, action.text)
273
- return
274
- }
472
+
473
+ const win = bot.currentWindow
474
+
475
+ if (action.type === 'trade' && win) {
476
+ if (ext.trade && isVillagerWindow(win)) {
477
+ await ext.trade(win, action.tradeIndex, 1)
478
+ } else if (isVillagerWindow(win)) {
479
+ await win.trade(action.tradeIndex, 1)
480
+ }
481
+ return
482
+ }
483
+
484
+ if (action.type === 'enchant' && win && isEnchantmentTableWindow(win)) {
485
+ await win.enchant(action.enchantIndex)
486
+ return
487
+ }
488
+
489
+ if (action.type === 'rename' && win && isAnvilWindow(win)) {
490
+ const w = win as { slots?: unknown[]; findInventoryItem?: (id: number) => unknown; rename: (item: unknown, name: string) => Promise<void> }
491
+ const inputSlot = w.slots?.[0] as { type?: number; metadata?: number; count?: number; nbt?: unknown } | null
492
+ const item = inputSlot?.type ? (w.findInventoryItem?.(inputSlot.type) ?? inputSlot) : null
493
+ if (item) await w.rename(item, action.text)
494
+ return
495
+ }
496
+
497
+ if (action.type === 'beacon' && win && isBeaconWindow(win)) {
498
+ if (typeof win.setBeaconEffects === 'function') {
499
+ await win.setBeaconEffects(action.primaryEffect, action.secondaryEffect)
500
+ } else if (ext._client) {
501
+ ext._client.write('beacon_effect', {
502
+ primaryEffect: action.primaryEffect,
503
+ secondaryEffect: action.secondaryEffect,
504
+ })
505
+ }
506
+ return
507
+ }
508
+
509
+ if (action.type === 'click' && action.mode === 'double') {
510
+ // bot.clickWindow() throws for mode=6 (prismarine-windows doubleClick is unimplemented).
511
+ // Send raw window_click packet directly, same approach as drag (mode=5).
512
+ if (!ext._client) return
513
+ const windowId = bot.currentWindow ? bot.currentWindow.id : 0
275
514
 
276
- if (action.type === 'beacon' && win && isBeaconWindow(win)) {
277
- if (typeof win.setBeaconEffects === 'function') {
278
- await win.setBeaconEffects(action.primaryEffect, action.secondaryEffect)
279
- } else if (ext._client) {
280
- ext._client.write('beacon_effect', {
281
- primaryEffect: action.primaryEffect,
282
- secondaryEffect: action.secondaryEffect,
515
+ ext._client.write('window_click', {
516
+ windowId,
517
+ stateId: dragStateId,
518
+ slot: action.slotIndex,
519
+ mouseButton: 0,
520
+ mode: 6,
521
+ changedSlots: [],
522
+ cursorItem: { present: false } as any,
283
523
  })
524
+ dragStateId++
525
+ return
284
526
  }
285
- return
286
- }
287
-
288
- if (action.type === 'click') {
289
- const [mouseButton, mode] = modeFromAction(action)
290
- await bot.clickWindow(action.slotIndex, mouseButton, mode)
291
- } else if (action.type === 'drop') {
292
- await bot.clickWindow(action.slotIndex, action.all ? 1 : 0, 4)
293
- } else if (action.type === 'close') {
294
- if (win) bot.closeWindow(win)
295
- } else if (action.type === 'hotbar-swap') {
296
- await bot.clickWindow(action.slotIndex, action.hotbarSlot, 2)
527
+
528
+ if (action.type === 'click') {
529
+ const [mouseButton, mode] = modeFromAction(action)
530
+ await bot.clickWindow(action.slotIndex, mouseButton, mode)
531
+ onSetSlot()
532
+ } else if (action.type === 'drag') {
533
+ // bot.clickWindow() throws for mode=5 (prismarine-windows dragClick is unimplemented).
534
+ // Send raw window_click packets directly via _client.write.
535
+ if (!ext._client) return
536
+ const isRight = action.button === 'right'
537
+ const startButton = isRight ? 4 : 0
538
+ const slotButton = isRight ? 5 : 1
539
+ const endButton = isRight ? 6 : 2
540
+ const windowId = bot.currentWindow ? bot.currentWindow.id : 0
541
+ const cursorItem = { present: false } as any
542
+
543
+ isDraggingRaw = true
544
+ const writeClick = (slot: number, mouseButton: number) => {
545
+ ext._client!.write('window_click', {
546
+ windowId,
547
+ stateId: dragStateId,
548
+ slot,
549
+ mouseButton,
550
+ mode: 5,
551
+ changedSlots: [],
552
+ cursorItem,
553
+ })
554
+ dragStateId++
555
+ }
556
+
557
+ writeClick(-999, startButton)
558
+ for (const slot of action.slots) {
559
+ writeClick(slot, slotButton)
560
+ }
561
+ writeClick(-999, endButton)
562
+ isDraggingRaw = false
563
+ } else if (action.type === 'drop') {
564
+ await bot.clickWindow(action.slotIndex, action.all ? 1 : 0, 4)
565
+ onSetSlot()
566
+ } else if (action.type === 'close') {
567
+ if (win) {
568
+ bot.closeWindow(win)
569
+ } else {
570
+ // Player inventory (synthetic) — send close_window so server drops cursor items
571
+ if (ext._client) {
572
+ ext._client.write('close_window', { windowId: 0 })
573
+ }
574
+ ;(bot.inventory as any).selectedItem = null
575
+ }
576
+ } else if (action.type === 'hotbar-swap') {
577
+ await bot.clickWindow(action.slotIndex, action.hotbarSlot, 2)
578
+ onSetSlot()
579
+ }
580
+ } catch (err) {
581
+ const detail = 'slotIndex' in action ? ` slot=${(action as any).slotIndex}` : ''
582
+ console.error(`[minecraft-inventory] sendAction "${action.type}"${detail} failed:`, err)
297
583
  }
298
584
  },
299
585
 
@@ -306,9 +592,29 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
306
592
  listeners.add(listener)
307
593
  return () => {
308
594
  listeners.delete(listener)
309
- bot.off('windowOpen', onWindowOpen)
310
- bot.off('windowClose', onWindowClose)
311
- bot.off('setSlot', onSetSlot)
595
+ bot.off('windowOpen', onWindowOpenInternal)
596
+ bot.off('windowClose', onWindowCloseInternal)
597
+ bot.off('setSlot:0' as any, onSetSlot)
598
+ bot.off('setWindowItems:0' as any, scheduleSlotUpdate)
599
+ bot.off('heldItemChanged' as any, onHeldItemChanged)
600
+ if (ext._client?.off) {
601
+ if (!hotbarOnly) {
602
+ ext._client.off('craft_progress_bar' as any, onCraftProgressBar as any)
603
+ }
604
+ for (const [event, listener] of rawPacketListeners) {
605
+ ext._client.off(event as any, listener as any)
606
+ }
607
+ }
608
+ if (currentWindowSlotEvent) {
609
+ bot.off(currentWindowSlotEvent as any, onSetSlot)
610
+ }
611
+ if (currentWindowItemsEvent) {
612
+ bot.off(currentWindowItemsEvent as any, scheduleSlotUpdate)
613
+ }
614
+ ;(bot.inventory as any).off('updateSlot', scheduleSlotUpdate)
615
+ if (bot.currentWindow) {
616
+ ;(bot.currentWindow as any).off('updateSlot', scheduleSlotUpdate)
617
+ }
312
618
  }
313
619
  },
314
620
  }