minecraft-inventory 0.1.24 → 0.1.26

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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ A flexible, scalable React library for rendering Minecraft inventory GUIs. Desig
9
9
  - `<img>`-rendered item textures with automatic `items/` → `blocks/` fallback (via PrismarineJS asset mirror by default)
10
10
  - Tooltips that follow the cursor, matching the original Minecraft style
11
11
  - Full keyboard support: number keys (1-9) to swap hotbar, Q to drop, double-click to collect, scroll wheel to pick/place
12
- - Mobile support: tap to open a context menu (take all / half / custom amount / drop)
12
+ - Mobile support: tap-to-focus inventory interactions plus long-press slot actions (pick half / custom amount / drop one / drop all)
13
13
  - Optional JEI (item browser) panel on the left or right
14
14
  - Bot connector layer — plugs into a mineflayer bot or any custom server connection
15
15
  - Demo connector with action logging for local development
@@ -414,13 +414,13 @@ const myConnector: InventoryConnector = {
414
414
 
415
415
  ## Mobile Support
416
416
 
417
- On touch devices, tapping a slot with no held item opens a context menu instead of immediately picking up the item. The menu appears to the side in landscape or below in portrait.
417
+ On touch devices, tapping a slot uses the mobile focus/swap flow. Long-pressing a populated slot with no held item opens a context menu. The menu appears to the side in landscape or below in portrait.
418
418
 
419
419
  Context menu options:
420
- - **Take All** — picks up the entire stack
421
- - **Take Half**picks up half
422
- - **Take Amount…**`window.prompt` to enter a custom quantity
423
- - **Drop** — drops the stack from the slot
420
+ - **Pick Half** — picks up half and highlights the selected slot
421
+ - **Pick Amount…**`window.prompt` to enter a custom quantity, remembering the last value entered
422
+ - **Drop One**drops a single item from the slot (same as `Q`)
423
+ - **Drop All** — drops the full stack from the slot (same as `Ctrl+Q`)
424
424
  - **Cancel**
425
425
 
426
426
  When you have a held item, tapping a slot places/transfers it (same as left-click on desktop).
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "minecraft-inventory",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
4
4
  "type": "module",
5
5
  "license": "MIT",
6
6
  "release": {
@@ -310,7 +310,7 @@ export function InventoryOverlay({
310
310
  lineHeight: 1,
311
311
  }}
312
312
  >
313
- INV 0.1.24
313
+ INV 0.1.26
314
314
  </a>
315
315
  )}
316
316
 
@@ -54,6 +54,8 @@ export function Slot({
54
54
  setPKeyActive,
55
55
  focusedSlot,
56
56
  setFocusedSlot,
57
+ mobilePickAmount,
58
+ setMobilePickAmount,
57
59
  dragEndedRef,
58
60
  noPlaceholders,
59
61
  } = useInventoryContext()
@@ -331,6 +333,7 @@ export function Slot({
331
333
 
332
334
  if (heldItem) {
333
335
  // When holding an item, place it (standard behavior, no focus needed)
336
+ if (focusedSlot !== null) setFocusedSlot(null)
334
337
  sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
335
338
  return
336
339
  }
@@ -351,28 +354,25 @@ export function Slot({
351
354
  [isMobile, disabled, heldItem, sendAction, index, pKeyActive, setPKeyActive, focusedSlot, setFocusedSlot, onClickOverride, cancelLongPress, mobileMenuOpen],
352
355
  )
353
356
 
354
- const handleMobilePickAll = useCallback(() => {
355
- setMobileMenuOpen(false)
356
- setShowTooltip(false)
357
- sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
358
- }, [sendAction, index])
359
-
360
357
  const handleMobilePickHalf = useCallback(() => {
361
358
  setMobileMenuOpen(false)
362
359
  setShowTooltip(false)
360
+ setFocusedSlot(index)
363
361
  sendAction({ type: 'click', slotIndex: index, button: 'right', mode: 'normal' })
364
- }, [sendAction, index])
362
+ }, [sendAction, index, setFocusedSlot])
365
363
 
366
364
  const handleMobilePickCustom = useCallback(() => {
367
365
  if (!item) return
368
- const input = window.prompt(`Pick amount (max ${item.count}):`, String(item.count))
366
+ const input = window.prompt(`Pick amount (max ${item.count}):`, String(mobilePickAmount))
369
367
  const amount = parseInt(input ?? '', 10)
370
368
  if (isNaN(amount) || amount <= 0) return
369
+ setMobilePickAmount(amount)
371
370
  setMobileMenuOpen(false)
372
371
  setShowTooltip(false)
372
+ setFocusedSlot(index)
373
373
  const take = Math.min(amount, item.count)
374
374
  if (take >= item.count) {
375
- // Take all: just left-click
375
+ // Pick all: just left-click
376
376
  sendAction({ type: 'click', slotIndex: index, button: 'left', mode: 'normal' })
377
377
  } else {
378
378
  // Pick up all, then put back (count - take) items one-by-one via right-click
@@ -381,13 +381,21 @@ export function Slot({
381
381
  sendAction({ type: 'click', slotIndex: index, button: 'right', mode: 'normal' })
382
382
  }
383
383
  }
384
- }, [item, sendAction, index])
384
+ }, [item, mobilePickAmount, sendAction, index, setFocusedSlot, setMobilePickAmount])
385
+
386
+ const handleMobileDropOne = useCallback(() => {
387
+ setMobileMenuOpen(false)
388
+ setShowTooltip(false)
389
+ setFocusedSlot(null)
390
+ sendAction({ type: 'drop', slotIndex: index, all: false })
391
+ }, [sendAction, index, setFocusedSlot])
385
392
 
386
- const handleMobileDrop = useCallback(() => {
393
+ const handleMobileDropAll = useCallback(() => {
387
394
  setMobileMenuOpen(false)
388
395
  setShowTooltip(false)
396
+ setFocusedSlot(null)
389
397
  sendAction({ type: 'drop', slotIndex: index, all: true })
390
- }, [sendAction, index])
398
+ }, [sendAction, index, setFocusedSlot])
391
399
 
392
400
  const closeMobileMenu = useCallback(() => {
393
401
  setMobileMenuOpen(false)
@@ -532,10 +540,10 @@ export function Slot({
532
540
  item={item}
533
541
  x={mobileTouchPos.x}
534
542
  y={mobileTouchPos.y}
535
- onPickAll={handleMobilePickAll}
536
543
  onPickHalf={handleMobilePickHalf}
537
544
  onPickCustom={handleMobilePickCustom}
538
- onDrop={handleMobileDrop}
545
+ onDropOne={handleMobileDropOne}
546
+ onDropAll={handleMobileDropAll}
539
547
  onClose={closeMobileMenu}
540
548
  />
541
549
  </>
@@ -548,14 +556,14 @@ interface MobileSlotMenuProps {
548
556
  item: ItemStack
549
557
  x: number
550
558
  y: number
551
- onPickAll(): void
552
559
  onPickHalf(): void
553
560
  onPickCustom(): void
554
- onDrop(): void
561
+ onDropOne(): void
562
+ onDropAll(): void
555
563
  onClose(): void
556
564
  }
557
565
 
558
- function MobileSlotMenu({ item, x, y, onPickAll, onPickHalf, onPickCustom, onDrop, onClose }: MobileSlotMenuProps) {
566
+ function MobileSlotMenu({ item, x, y, onPickHalf, onPickCustom, onDropOne, onDropAll, onClose }: MobileSlotMenuProps) {
559
567
  const { scale } = useScale()
560
568
  const menuRef = useRef<HTMLDivElement>(null)
561
569
  const [pos, setPos] = useState({ left: x, top: y })
@@ -600,10 +608,10 @@ function MobileSlotMenu({ item, x, y, onPickAll, onPickHalf, onPickCustom, onDro
600
608
  <div className={styles.mobileMenuTitle}>
601
609
  {item.displayName ?? item.name ?? `Item #${item.type}`} ×{item.count}
602
610
  </div>
603
- <button className={styles.mobileBtn} {...touchBtn(onPickAll)}>Take All ({item.count})</button>
604
- <button className={styles.mobileBtn} {...touchBtn(onPickHalf)}>Take Half ({Math.ceil(item.count / 2)})</button>
605
- <button className={styles.mobileBtn} {...touchBtn(onPickCustom)}>Take Amount…</button>
606
- <button className={[styles.mobileBtn, styles.mobileBtnDanger].join(' ')} {...touchBtn(onDrop)}>Drop</button>
611
+ <button className={styles.mobileBtn} {...touchBtn(onPickHalf)}>Pick Half ({Math.ceil(item.count / 2)})</button>
612
+ <button className={styles.mobileBtn} {...touchBtn(onPickCustom)}>Pick Amount…</button>
613
+ <button className={[styles.mobileBtn, styles.mobileBtnDanger].join(' ')} {...touchBtn(onDropOne)}>Drop One</button>
614
+ <button className={[styles.mobileBtn, styles.mobileBtnDanger].join(' ')} {...touchBtn(onDropAll)}>Drop All</button>
607
615
  <button className={styles.mobileBtn} {...touchBtn(onClose)}>Cancel</button>
608
616
  </div>
609
617
  )
@@ -1,6 +1,15 @@
1
1
  import type { InventoryAction, InventoryWindowState, PlayerState, SlotState, ItemStack } from '../types'
2
2
  import type { InventoryConnector, ConnectorListener, ConnectorEvent, MineflayerBot } from './types'
3
3
  import { getInventoryType } from '../registry'
4
+ import {
5
+ createInventoryDebugSession,
6
+ getActionSlotIndexes,
7
+ sanitizeDebugValue,
8
+ summarizeAction,
9
+ summarizeItem,
10
+ summarizeSlots,
11
+ summarizeWindowState,
12
+ } from '../debug/inventoryDebug'
4
13
 
5
14
  type RawSlot = { type: number; count: number; metadata?: number; nbt?: unknown }
6
15
 
@@ -170,6 +179,24 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
170
179
  // stale server responses overwriting our predicted stateId mid-sequence.
171
180
  let isDraggingRaw = false
172
181
 
182
+ const debugSession = createInventoryDebugSession(
183
+ hotbarOnly ? 'mineflayer-connector:hotbar' : 'mineflayer-connector',
184
+ () => {
185
+ const state = buildWindowState()
186
+ return {
187
+ windowState: state,
188
+ playerState: buildPlayerState(),
189
+ heldItem: state?.heldItem ?? null,
190
+ }
191
+ },
192
+ )
193
+ debugSession.log({
194
+ event: 'connector.mount',
195
+ data: {
196
+ hotbarOnly,
197
+ },
198
+ })
199
+
173
200
  // Resolve the Item class (prismarine-item) for converting notch-format items.
174
201
  // We extract it lazily from the first non-null slot item's constructor.
175
202
  let ItemClass: { fromNotch(notch: unknown): unknown } | null = null
@@ -318,6 +345,58 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
318
345
  }
319
346
  }
320
347
 
348
+ function logActionEvent(
349
+ event: string,
350
+ action: InventoryAction,
351
+ data?: Record<string, unknown>,
352
+ ) {
353
+ const state = buildWindowState()
354
+ const slotIndexes = getActionSlotIndexes(action)
355
+ debugSession.log({
356
+ event,
357
+ windowId: state?.windowId,
358
+ windowType: state?.type,
359
+ action: summarizeAction(action),
360
+ stateId: dragStateId,
361
+ slots: state ? summarizeSlots(state.slots, slotIndexes) : undefined,
362
+ heldItem: summarizeItem(state?.heldItem),
363
+ data: data ? sanitizeDebugValue(data) : summarizeWindowState(state, slotIndexes),
364
+ })
365
+ }
366
+
367
+ function logPacketWrite(packet: string, params: unknown) {
368
+ const state = buildWindowState()
369
+ const paramsObject = params && typeof params === 'object' ? params as Record<string, unknown> : {}
370
+ debugSession.log({
371
+ event: 'connector.packetWrite',
372
+ windowId: typeof paramsObject.windowId === 'number' ? paramsObject.windowId : state?.windowId,
373
+ windowType: state?.type,
374
+ stateId: typeof paramsObject.stateId === 'number' ? paramsObject.stateId : dragStateId,
375
+ data: {
376
+ packet,
377
+ params: sanitizeDebugValue(params),
378
+ },
379
+ })
380
+ }
381
+
382
+ function logHelperIntent(action: InventoryAction, helper: string, params: Record<string, unknown>) {
383
+ const state = buildWindowState()
384
+ const slotIndexes = getActionSlotIndexes(action)
385
+ debugSession.log({
386
+ event: 'connector.helperIntent',
387
+ windowId: state?.windowId,
388
+ windowType: state?.type,
389
+ action: summarizeAction(action),
390
+ stateId: dragStateId,
391
+ slots: state ? summarizeSlots(state.slots, slotIndexes) : undefined,
392
+ heldItem: summarizeItem(state?.heldItem),
393
+ data: {
394
+ helper,
395
+ params: sanitizeDebugValue(params),
396
+ },
397
+ })
398
+ }
399
+
321
400
  /** Compute anvil cost client-side if callback is provided and window is anvil. */
322
401
  function tryComputeAnvilCost() {
323
402
  if (!computeAnvilCost || !currentWindowType) return
@@ -516,26 +595,40 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
516
595
  openPlayerInventory,
517
596
 
518
597
  sendAction: async (action: InventoryAction) => {
598
+ logActionEvent('connector.action.start', action)
519
599
  try {
520
600
  // Hotbar "open inventory" button — delegates to openPlayerInventory()
521
601
  if (action.type === 'open-inventory') {
522
602
  await openPlayerInventory()
603
+ logActionEvent('connector.action.success', action)
523
604
  return
524
605
  }
525
606
 
526
607
  const win = bot.currentWindow
527
608
 
528
609
  if (action.type === 'trade' && win) {
610
+ let handled = false
529
611
  if (ext.trade && isVillagerWindow(win)) {
612
+ logHelperIntent(action, 'bot.trade', { tradeIndex: action.tradeIndex, count: 1 })
530
613
  await ext.trade(win, action.tradeIndex, 1)
614
+ handled = true
531
615
  } else if (isVillagerWindow(win)) {
616
+ logHelperIntent(action, 'window.trade', { tradeIndex: action.tradeIndex, count: 1 })
532
617
  await win.trade(action.tradeIndex, 1)
618
+ handled = true
619
+ }
620
+ if (handled) {
621
+ logActionEvent('connector.action.success', action)
622
+ } else {
623
+ logActionEvent('connector.action.skipped', action, { reason: 'missing_trade_handler' })
533
624
  }
534
625
  return
535
626
  }
536
627
 
537
628
  if (action.type === 'enchant' && win && isEnchantmentTableWindow(win)) {
629
+ logHelperIntent(action, 'window.enchant', { enchantIndex: action.enchantIndex })
538
630
  await win.enchant(action.enchantIndex)
631
+ logActionEvent('connector.action.success', action)
539
632
  return
540
633
  }
541
634
 
@@ -544,23 +637,44 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
544
637
  // because it tries to transfer items from player inventory into anvil,
545
638
  // which fails when the user already placed items via the UI.
546
639
  if (ext.supportFeature?.('useMCItemName')) {
547
- if (!ext._client.registerChannel) return
640
+ if (!ext._client.registerChannel) {
641
+ logActionEvent('connector.action.skipped', action, { reason: 'missing_registerChannel' })
642
+ return
643
+ }
548
644
  ext._client.registerChannel('MC|ItemName', 'string')
645
+ logPacketWrite('MC|ItemName', action.text)
549
646
  ext._client.writeChannel?.('MC|ItemName', action.text)
550
647
  } else {
551
- ext._client.write('name_item', { name: action.text })
648
+ const packet = { name: action.text }
649
+ logPacketWrite('name_item', packet)
650
+ ext._client.write('name_item', packet)
552
651
  }
652
+ logActionEvent('connector.action.success', action)
553
653
  return
554
654
  }
555
655
 
556
656
  if (action.type === 'beacon' && win && isBeaconWindow(win)) {
657
+ let handled = false
557
658
  if (typeof win.setBeaconEffects === 'function') {
659
+ logHelperIntent(action, 'window.setBeaconEffects', {
660
+ primaryEffect: action.primaryEffect,
661
+ secondaryEffect: action.secondaryEffect,
662
+ })
558
663
  await win.setBeaconEffects(action.primaryEffect, action.secondaryEffect)
664
+ handled = true
559
665
  } else if (ext._client) {
560
- ext._client.write('beacon_effect', {
666
+ const packet = {
561
667
  primaryEffect: action.primaryEffect,
562
668
  secondaryEffect: action.secondaryEffect,
563
- })
669
+ }
670
+ logPacketWrite('beacon_effect', packet)
671
+ ext._client.write('beacon_effect', packet)
672
+ handled = true
673
+ }
674
+ if (handled) {
675
+ logActionEvent('connector.action.success', action)
676
+ } else {
677
+ logActionEvent('connector.action.skipped', action, { reason: 'missing_beacon_handler' })
564
678
  }
565
679
  return
566
680
  }
@@ -568,10 +682,13 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
568
682
  if (action.type === 'click' && action.mode === 'double') {
569
683
  // bot.clickWindow() throws for mode=6 (prismarine-windows doubleClick is unimplemented).
570
684
  // Send raw window_click packet directly, same approach as drag (mode=5).
571
- if (!ext._client) return
685
+ if (!ext._client) {
686
+ logActionEvent('connector.action.skipped', action, { reason: 'missing_client' })
687
+ return
688
+ }
572
689
  const windowId = bot.currentWindow ? bot.currentWindow.id : 0
573
690
 
574
- ext._client.write('window_click', {
691
+ const packet = {
575
692
  windowId,
576
693
  stateId: dragStateId,
577
694
  slot: action.slotIndex,
@@ -579,19 +696,31 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
579
696
  mode: 6,
580
697
  changedSlots: [],
581
698
  cursorItem: { present: false } as any,
582
- })
699
+ }
700
+ logPacketWrite('window_click', packet)
701
+ ext._client.write('window_click', packet)
583
702
  dragStateId++
703
+ logActionEvent('connector.action.success', action)
584
704
  return
585
705
  }
586
706
 
587
707
  if (action.type === 'click') {
588
708
  const [mouseButton, mode] = modeFromAction(action)
709
+ logHelperIntent(action, 'bot.clickWindow', {
710
+ slot: action.slotIndex,
711
+ mouseButton,
712
+ mode,
713
+ })
589
714
  await bot.clickWindow(action.slotIndex, mouseButton, mode)
590
715
  onSetSlot()
716
+ logActionEvent('connector.action.success', action)
591
717
  } else if (action.type === 'drag') {
592
718
  // bot.clickWindow() throws for mode=5 (prismarine-windows dragClick is unimplemented).
593
719
  // Send raw window_click packets directly via _client.write.
594
- if (!ext._client) return
720
+ if (!ext._client) {
721
+ logActionEvent('connector.action.skipped', action, { reason: 'missing_client' })
722
+ return
723
+ }
595
724
  const isRight = action.button === 'right'
596
725
  const startButton = isRight ? 4 : 0
597
726
  const slotButton = isRight ? 5 : 1
@@ -600,43 +729,68 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
600
729
  const cursorItem = { present: false } as any
601
730
 
602
731
  isDraggingRaw = true
603
- const writeClick = (slot: number, mouseButton: number) => {
604
- ext._client!.write('window_click', {
605
- windowId,
606
- stateId: dragStateId,
607
- slot,
608
- mouseButton,
609
- mode: 5,
610
- changedSlots: [],
611
- cursorItem,
612
- })
613
- dragStateId++
614
- }
615
-
616
- writeClick(-999, startButton)
617
- for (const slot of action.slots) {
618
- writeClick(slot, slotButton)
732
+ try {
733
+ const writeClick = (slot: number, mouseButton: number) => {
734
+ const packet = {
735
+ windowId,
736
+ stateId: dragStateId,
737
+ slot,
738
+ mouseButton,
739
+ mode: 5,
740
+ changedSlots: [],
741
+ cursorItem,
742
+ }
743
+ logPacketWrite('window_click', packet)
744
+ ext._client!.write('window_click', packet)
745
+ dragStateId++
746
+ }
747
+
748
+ writeClick(-999, startButton)
749
+ for (const slot of action.slots) {
750
+ writeClick(slot, slotButton)
751
+ }
752
+ writeClick(-999, endButton)
753
+ } finally {
754
+ isDraggingRaw = false
619
755
  }
620
- writeClick(-999, endButton)
621
- isDraggingRaw = false
756
+ logActionEvent('connector.action.success', action)
622
757
  } else if (action.type === 'drop') {
758
+ logHelperIntent(action, 'bot.clickWindow', {
759
+ slot: action.slotIndex,
760
+ mouseButton: action.all ? 1 : 0,
761
+ mode: 4,
762
+ })
623
763
  await bot.clickWindow(action.slotIndex, action.all ? 1 : 0, 4)
624
764
  onSetSlot()
765
+ logActionEvent('connector.action.success', action)
625
766
  } else if (action.type === 'close') {
626
767
  if (win) {
768
+ logHelperIntent(action, 'bot.closeWindow', { windowId: win.id })
627
769
  bot.closeWindow(win)
628
770
  } else {
629
771
  // Player inventory (synthetic) — send close_window so server drops cursor items
630
772
  if (ext._client) {
631
- ext._client.write('close_window', { windowId: 0 })
773
+ const packet = { windowId: 0 }
774
+ logPacketWrite('close_window', packet)
775
+ ext._client.write('close_window', packet)
632
776
  }
633
777
  ;(bot.inventory as any).selectedItem = null
634
778
  }
779
+ logActionEvent('connector.action.success', action)
635
780
  } else if (action.type === 'hotbar-swap') {
781
+ logHelperIntent(action, 'bot.clickWindow', {
782
+ slot: action.slotIndex,
783
+ mouseButton: action.hotbarSlot,
784
+ mode: 2,
785
+ })
636
786
  await bot.clickWindow(action.slotIndex, action.hotbarSlot, 2)
637
787
  onSetSlot()
788
+ logActionEvent('connector.action.success', action)
789
+ } else {
790
+ logActionEvent('connector.action.ignored', action, { reason: 'no_matching_handler' })
638
791
  }
639
792
  } catch (err) {
793
+ logActionEvent('connector.action.failure', action, { error: err instanceof Error ? err.message : String(err) })
640
794
  const detail = 'slotIndex' in action ? ` slot=${(action as any).slotIndex}` : ''
641
795
  console.error(`[minecraft-inventory] sendAction "${action.type}"${detail} failed:`, err)
642
796
  }
@@ -674,7 +828,9 @@ export function createMineflayerConnector(bot: MineflayerBot, options?: Mineflay
674
828
  if (bot.currentWindow) {
675
829
  ;(bot.currentWindow as any).off('updateSlot', scheduleSlotUpdate)
676
830
  }
831
+ debugSession.log({ event: 'connector.unmount' })
832
+ debugSession.dispose()
677
833
  }
678
834
  },
679
835
  }
680
- }
836
+ }
@@ -2,6 +2,15 @@ import React, { createContext, useContext, useEffect, useRef, useState, useCallb
2
2
  import type { InventoryWindowState, PlayerState, ItemStack, SlotState } from '../types'
3
3
  import type { InventoryConnector } from '../connector/types'
4
4
  import { isItemEqual, getMaxStackSize } from '../utils/isItemEqual'
5
+ import {
6
+ createInventoryDebugSession,
7
+ getActionSlotIndexes,
8
+ summarizeAction,
9
+ summarizeItem,
10
+ summarizeSlots,
11
+ summarizeWindowState,
12
+ type InventoryDebugSession,
13
+ } from '../debug/inventoryDebug'
5
14
 
6
15
  export interface DragPreviewEntry {
7
16
  count: number
@@ -44,6 +53,9 @@ export interface InventoryContextValue {
44
53
  /** Pending first digit for P-key slot number entry */
45
54
  pKeyDigit: string
46
55
  setPKeyDigit: (d: string) => void
56
+ /** Last amount entered in the mobile pick-amount prompt */
57
+ mobilePickAmount: number
58
+ setMobilePickAmount: (amount: number) => void
47
59
  /** Ref set to true when a drag just ended; cleared on next mouseDown.
48
60
  * Used by Slot to suppress spurious click events that fire after endDrag. */
49
61
  dragEndedRef: React.MutableRefObject<boolean>
@@ -90,6 +102,7 @@ export function InventoryProvider({ connector, children, noDragSpread = false, n
90
102
  const [pKeyActive, setPKeyActive] = useState(false)
91
103
  const [focusedSlot, setFocusedSlot] = useState<number | null>(null)
92
104
  const [pKeyDigit, setPKeyDigit] = useState('')
105
+ const [mobilePickAmount, setMobilePickAmount] = useState(1)
93
106
 
94
107
  const connectorRef = useRef(connector)
95
108
  connectorRef.current = connector
@@ -105,8 +118,30 @@ export function InventoryProvider({ connector, children, noDragSpread = false, n
105
118
  // Set to true when endDrag fires; cleared on the next mouseDown in Slot.
106
119
  // Prevents spurious mouseUp events from sending unwanted clicks after a drag.
107
120
  const dragEndedRef = useRef(false)
108
- /** Latest full context value; updated every render for global debug (`globalThis.__mcInv.state`). */
121
+ /** Latest full context value; used by the DevTools debug API (`globalThis.__mcInv.state`). */
109
122
  const valueRef = useRef<InventoryContextValue | null>(null)
123
+ const debugSessionRef = useRef<InventoryDebugSession | null>(null)
124
+
125
+ useEffect(() => {
126
+ const session = createInventoryDebugSession('inventory-provider', () => {
127
+ const value = valueRef.current
128
+ if (!value) return null
129
+ return {
130
+ windowState: value.windowState,
131
+ playerState: value.playerState,
132
+ heldItem: value.heldItem,
133
+ isDragging: value.isDragging,
134
+ dragSlots: [...value.dragSlots],
135
+ }
136
+ })
137
+ debugSessionRef.current = session
138
+ session.log({ event: 'provider.mount' })
139
+ return () => {
140
+ session.log({ event: 'provider.unmount' })
141
+ session.dispose()
142
+ if (debugSessionRef.current === session) debugSessionRef.current = null
143
+ }
144
+ }, [])
110
145
 
111
146
  useEffect(() => {
112
147
  if (!connector) return
@@ -115,16 +150,35 @@ export function InventoryProvider({ connector, children, noDragSpread = false, n
115
150
 
116
151
  return connector.subscribe((event) => {
117
152
  if (event.type === 'windowOpen' || event.type === 'windowUpdate') {
153
+ debugSessionRef.current?.log({
154
+ event: `connector.${event.type}`,
155
+ windowId: event.state.windowId,
156
+ windowType: event.state.type,
157
+ slots: summarizeSlots(event.state.slots),
158
+ heldItem: summarizeItem(event.state.heldItem),
159
+ })
118
160
  setWindowState({ ...event.state })
119
161
  setHeldItemState(event.state.heldItem)
120
162
  } else if (event.type === 'windowClose') {
163
+ debugSessionRef.current?.log({ event: 'connector.windowClose' })
121
164
  setWindowState(null)
122
165
  setHeldItemState(null)
123
166
  setIsDragging(false)
124
167
  setDragSlots([])
125
168
  } else if (event.type === 'playerUpdate') {
169
+ debugSessionRef.current?.log({
170
+ event: 'connector.playerUpdate',
171
+ slots: summarizeSlots(event.state.inventory),
172
+ data: {
173
+ activeHotbarSlot: event.state.activeHotbarSlot,
174
+ },
175
+ })
126
176
  setPlayerState(event.state)
127
177
  } else if (event.type === 'heldItemChange') {
178
+ debugSessionRef.current?.log({
179
+ event: 'connector.heldItemChange',
180
+ heldItem: summarizeItem(event.item),
181
+ })
128
182
  setHeldItemState(event.item)
129
183
  }
130
184
  })
@@ -136,6 +190,19 @@ export function InventoryProvider({ connector, children, noDragSpread = false, n
136
190
 
137
191
  const sendAction = useCallback<InventoryConnector['sendAction']>(
138
192
  (action) => {
193
+ const slotIndexes = getActionSlotIndexes(action)
194
+ const state = valueRef.current?.windowState ?? connectorRef.current?.getWindowState() ?? null
195
+ debugSessionRef.current?.log({
196
+ event: 'ui.action',
197
+ windowId: state?.windowId,
198
+ windowType: state?.type,
199
+ action: summarizeAction(action),
200
+ slots: state ? summarizeSlots(state.slots, slotIndexes) : undefined,
201
+ heldItem: summarizeItem(valueRef.current?.heldItem ?? state?.heldItem),
202
+ data: {
203
+ before: summarizeWindowState(state, slotIndexes),
204
+ },
205
+ })
139
206
  return connectorRef.current?.sendAction(action)
140
207
  },
141
208
  [],
@@ -210,6 +277,17 @@ export function InventoryProvider({ connector, children, noDragSpread = false, n
210
277
 
211
278
  // Only send drag action if multiple slots were involved (single slot = normal click)
212
279
  if (slots.length > 1 && button && held) {
280
+ debugSessionRef.current?.log({
281
+ event: 'ui.drag.optimisticUpdate',
282
+ windowId: ws?.windowId,
283
+ windowType: ws?.type,
284
+ action: summarizeAction({ type: 'drag', slots, button }),
285
+ slots: ws ? summarizeSlots(ws.slots, slots) : undefined,
286
+ heldItem: summarizeItem(held),
287
+ data: {
288
+ before: summarizeWindowState(ws, slots),
289
+ },
290
+ })
213
291
  connectorRef.current?.sendAction({ type: 'drag', slots, button })
214
292
 
215
293
  // Optimistic client-side update: apply item distribution immediately
@@ -320,24 +398,13 @@ export function InventoryProvider({ connector, children, noDragSpread = false, n
320
398
  setFocusedSlot,
321
399
  pKeyDigit,
322
400
  setPKeyDigit,
401
+ mobilePickAmount,
402
+ setMobilePickAmount,
323
403
  dragEndedRef,
324
404
  resolveEnchantmentName,
325
405
  }
326
406
 
327
407
  valueRef.current = value
328
408
 
329
- // Full inventory UI state on global for DevTools: globalThis.__mcInv.state (e.g. windowState, playerState, heldItem, drag state).
330
- useEffect(() => {
331
- const g = globalThis as any
332
- g.__mcInv = {
333
- get state() {
334
- return valueRef.current
335
- },
336
- }
337
- return () => {
338
- delete g.__mcInv
339
- }
340
- }, [])
341
-
342
409
  return <InventoryContext.Provider value={value}>{children}</InventoryContext.Provider>
343
410
  }
@@ -0,0 +1,69 @@
1
+ # Inventory Debug API
2
+
3
+ Глобальный объект `__mcInv` доступен в DevTools сразу после монтирования `InventoryProvider`.
4
+
5
+ ## Быстрый старт
6
+
7
+ ```js
8
+ // Текущее состояние инвентаря
9
+ __mcInv.state
10
+
11
+ // Все сессии (провайдер + коннектор)
12
+ __mcInv.getStates()
13
+
14
+ // Лог событий
15
+ __mcInv.getLogs()
16
+
17
+ // Последние N событий
18
+ __mcInv.getLogs().slice(-20)
19
+
20
+ // Экспорт для сравнения / передачи
21
+ copy(JSON.stringify(__mcInv.exportLogs(), null, 2))
22
+
23
+ // Очистить буфер
24
+ __mcInv.clearLogs()
25
+ ```
26
+
27
+ ## События
28
+
29
+ | Событие | Источник | Когда |
30
+ |---|---|---|
31
+ | `connector.mount` | mineflayer-connector | Коннектор создан |
32
+ | `connector.unmount` | mineflayer-connector | Коннектор уничтожен |
33
+ | `provider.mount` | inventory-provider | Провайдер смонтирован |
34
+ | `provider.unmount` | inventory-provider | Провайдер размонтирован |
35
+ | `connector.windowOpen` | inventory-provider | Открыто новое окно |
36
+ | `connector.windowUpdate` | inventory-provider | Слоты обновились от сервера |
37
+ | `connector.windowClose` | inventory-provider | Окно закрыто |
38
+ | `connector.playerUpdate` | inventory-provider | Обновился инвентарь игрока |
39
+ | `connector.heldItemChange` | inventory-provider | Изменился предмет в руке |
40
+ | `ui.action` | inventory-provider | Пользователь инициировал действие |
41
+ | `ui.drag.optimisticUpdate` | inventory-provider | Drag завершён, применён оптимистичный апдейт |
42
+ | `connector.action.start` | mineflayer-connector | Начало отправки пакета |
43
+ | `connector.action.success` | mineflayer-connector | Пакет успешно отправлен |
44
+ | `connector.action.failure` | mineflayer-connector | Ошибка при отправке |
45
+ | `connector.action.skipped` | mineflayer-connector | Действие пропущено (нет нужного хендлера) |
46
+ | `connector.action.ignored` | mineflayer-connector | Неизвестный тип действия |
47
+ | `connector.helperIntent` | mineflayer-connector | Вызов mineflayer-хелпера (bot.clickWindow и др.) |
48
+ | `connector.packetWrite` | mineflayer-connector | Прямая запись пакета через `_client.write` |
49
+
50
+ ## Фильтрация логов
51
+
52
+ ```js
53
+ // Только ошибки
54
+ __mcInv.getLogs().filter(e => e.event === 'connector.action.failure')
55
+
56
+ // Только действия пользователя
57
+ __mcInv.getLogs().filter(e => e.event.startsWith('ui.'))
58
+
59
+ // Конкретное окно
60
+ __mcInv.getLogs().filter(e => e.windowType === 'enchanting_table')
61
+
62
+ // Пакеты конкретного типа
63
+ __mcInv.getLogs().filter(e => e.event === 'connector.packetWrite' && e.data?.packet === 'window_click')
64
+ ```
65
+
66
+ ## Ограничения
67
+
68
+ - Буфер хранит последние **1000** записей. При активном геймплее старые события вытесняются — используйте `exportLogs()` своевременно.
69
+ - `__mcInv.state` всегда показывает состояние последней созданной сессии (обычно `inventory-provider`). Для состояния коннектора используйте `getStates()`.
@@ -0,0 +1,240 @@
1
+ import type { InventoryAction, InventoryWindowState, ItemStack, PlayerState, SlotState } from '../types'
2
+
3
+ type JsonPrimitive = string | number | boolean | null
4
+ type JsonValue = JsonPrimitive | JsonValue[] | { [key: string]: JsonValue }
5
+
6
+ export interface InventoryDebugLogEntry {
7
+ id: number
8
+ time: number
9
+ source: string
10
+ event: string
11
+ sessionId?: string
12
+ windowId?: number
13
+ windowType?: string
14
+ action?: JsonValue
15
+ stateId?: number
16
+ slots?: JsonValue
17
+ heldItem?: JsonValue
18
+ data?: JsonValue
19
+ error?: JsonValue
20
+ }
21
+
22
+ export interface InventoryDebugState {
23
+ windowState: InventoryWindowState | null
24
+ playerState: PlayerState | null
25
+ heldItem: ItemStack | null
26
+ isDragging?: boolean
27
+ dragSlots?: number[]
28
+ }
29
+
30
+ export interface InventoryDebugApi {
31
+ readonly state: InventoryDebugState | null
32
+ getStates(): Array<{ sessionId: string; source: string; state: InventoryDebugState | null }>
33
+ getLogs(): InventoryDebugLogEntry[]
34
+ clearLogs(): void
35
+ exportLogs(): {
36
+ exportedAt: string
37
+ logs: InventoryDebugLogEntry[]
38
+ states: JsonValue
39
+ }
40
+ }
41
+
42
+ interface ProviderRegistration {
43
+ sessionId: string
44
+ source: string
45
+ getState: () => InventoryDebugState | null
46
+ }
47
+
48
+ export interface InventoryDebugSession {
49
+ sessionId: string
50
+ source: string
51
+ log(entry: Omit<InventoryDebugLogEntry, 'id' | 'time' | 'source' | 'sessionId'>): void
52
+ dispose(): void
53
+ }
54
+
55
+ const MAX_LOGS = 1000
56
+ const MAX_OBJECT_DEPTH = 4
57
+ const MAX_ARRAY_ITEMS = 80
58
+
59
+ let nextLogId = 1
60
+ let nextSessionId = 1
61
+ const logs: InventoryDebugLogEntry[] = []
62
+ const providers = new Map<string, ProviderRegistration>()
63
+ let activeSessionId: string | null = null
64
+
65
+ function isObject(value: unknown): value is Record<string, unknown> {
66
+ return typeof value === 'object' && value !== null
67
+ }
68
+
69
+ function sanitize(value: unknown, depth = 0, seen = new WeakSet<object>()): JsonValue {
70
+ if (value == null) return null
71
+ if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') return value
72
+ if (typeof value === 'bigint') return String(value)
73
+ if (typeof value === 'function' || typeof value === 'symbol' || typeof value === 'undefined') return null
74
+ if (depth >= MAX_OBJECT_DEPTH) return '[truncated]'
75
+
76
+ if (Array.isArray(value)) {
77
+ return value.slice(0, MAX_ARRAY_ITEMS).map((item) => sanitize(item, depth + 1, seen))
78
+ }
79
+
80
+ if (value instanceof Uint8Array) {
81
+ return `[Uint8Array:${value.byteLength}]`
82
+ }
83
+
84
+ if (!isObject(value)) return String(value)
85
+ if (seen.has(value)) return '[circular]'
86
+ seen.add(value)
87
+
88
+ const output: Record<string, JsonValue> = {}
89
+ for (const [key, entryValue] of Object.entries(value)) {
90
+ if (key === 'nbt') {
91
+ output.hasNbt = entryValue != null
92
+ continue
93
+ }
94
+ output[key] = sanitize(entryValue, depth + 1, seen)
95
+ }
96
+ return output
97
+ }
98
+
99
+ export function summarizeItem(item: ItemStack | null | undefined): JsonValue {
100
+ if (!item) return null
101
+ const summary: Record<string, JsonValue> = {
102
+ type: item.type,
103
+ count: item.count,
104
+ }
105
+ if (item.metadata !== undefined) summary.metadata = item.metadata
106
+ if (item.name) summary.name = item.name
107
+ if (item.displayName) summary.displayName = item.displayName
108
+ if (item.debugKey) summary.debugKey = item.debugKey
109
+ if (item.nbt) summary.hasNbt = true
110
+ if (item.enchantments?.length) summary.enchantments = sanitize(item.enchantments)
111
+ return summary
112
+ }
113
+
114
+ export function summarizeSlots(slots: SlotState[] | undefined, slotIndexes?: number[]): JsonValue {
115
+ if (!slots) return []
116
+ const filter = slotIndexes ? new Set(slotIndexes) : null
117
+ const selected = filter ? slots.filter((slot) => filter.has(slot.index)) : slots.filter((slot) => slot.item)
118
+ return selected.map((slot) => ({
119
+ index: slot.index,
120
+ item: summarizeItem(slot.item),
121
+ }))
122
+ }
123
+
124
+ export function summarizeAction(action: InventoryAction): JsonValue {
125
+ return sanitize(action)
126
+ }
127
+
128
+ export function sanitizeDebugValue(value: unknown): JsonValue {
129
+ return sanitize(value)
130
+ }
131
+
132
+ export function summarizeWindowState(state: InventoryWindowState | null | undefined, slotIndexes?: number[]): JsonValue {
133
+ if (!state) return null
134
+ return {
135
+ windowId: state.windowId,
136
+ type: state.type,
137
+ title: state.title ?? null,
138
+ heldItem: summarizeItem(state.heldItem),
139
+ properties: sanitize(state.properties ?? null),
140
+ slots: summarizeSlots(state.slots, slotIndexes),
141
+ }
142
+ }
143
+
144
+ export function getActionSlotIndexes(action: InventoryAction): number[] | undefined {
145
+ if (action.type === 'click' || action.type === 'drop' || action.type === 'hotbar-swap') return [action.slotIndex]
146
+ if (action.type === 'drag') return [...action.slots]
147
+ return undefined
148
+ }
149
+
150
+ function getActiveState(): InventoryDebugState | null {
151
+ if (activeSessionId && providers.has(activeSessionId)) {
152
+ return providers.get(activeSessionId)!.getState()
153
+ }
154
+ const last = [...providers.values()].at(-1)
155
+ return last?.getState() ?? null
156
+ }
157
+
158
+ function getStates(): Array<{ sessionId: string; source: string; state: InventoryDebugState | null }> {
159
+ return [...providers.values()].map((provider) => ({
160
+ sessionId: provider.sessionId,
161
+ source: provider.source,
162
+ state: provider.getState(),
163
+ }))
164
+ }
165
+
166
+ function installGlobalApi() {
167
+ const g = globalThis as typeof globalThis & { __mcInv?: InventoryDebugApi }
168
+ if (g.__mcInv?.getLogs === getLogs) return
169
+
170
+ const api: InventoryDebugApi = {
171
+ get state() {
172
+ return getActiveState()
173
+ },
174
+ getStates,
175
+ getLogs,
176
+ clearLogs,
177
+ exportLogs,
178
+ }
179
+ g.__mcInv = api
180
+ }
181
+
182
+ function getLogs(): InventoryDebugLogEntry[] {
183
+ return logs.map((entry) => ({ ...entry }))
184
+ }
185
+
186
+ function clearLogs() {
187
+ logs.length = 0
188
+ }
189
+
190
+ function exportLogs() {
191
+ return {
192
+ exportedAt: new Date().toISOString(),
193
+ logs: getLogs(),
194
+ states: sanitize(getStates()),
195
+ }
196
+ }
197
+
198
+ export function logInventoryDebug(entry: Omit<InventoryDebugLogEntry, 'id' | 'time'>): void {
199
+ const fullEntry: InventoryDebugLogEntry = {
200
+ ...entry,
201
+ id: nextLogId++,
202
+ time: Date.now(),
203
+ }
204
+ logs.push(fullEntry)
205
+ if (logs.length > MAX_LOGS) logs.splice(0, logs.length - MAX_LOGS)
206
+ installGlobalApi()
207
+ }
208
+
209
+ export function createInventoryDebugSession(
210
+ source: string,
211
+ getState: () => InventoryDebugState | null,
212
+ ): InventoryDebugSession {
213
+ installGlobalApi()
214
+ const sessionId = `${source}-${nextSessionId++}`
215
+ providers.set(sessionId, { sessionId, source, getState })
216
+ activeSessionId = sessionId
217
+
218
+ return {
219
+ sessionId,
220
+ source,
221
+ log(entry) {
222
+ logInventoryDebug({
223
+ ...entry,
224
+ source,
225
+ sessionId,
226
+ })
227
+ },
228
+ dispose() {
229
+ providers.delete(sessionId)
230
+ if (activeSessionId === sessionId) {
231
+ activeSessionId = [...providers.keys()].at(-1) ?? null
232
+ }
233
+ },
234
+ }
235
+ }
236
+
237
+ export function getInventoryDebugApi(): InventoryDebugApi {
238
+ installGlobalApi()
239
+ return (globalThis as typeof globalThis & { __mcInv: InventoryDebugApi }).__mcInv
240
+ }
package/src/index.tsx CHANGED
@@ -36,6 +36,23 @@ export { useKeyboardShortcuts } from './hooks/useKeyboardShortcuts'
36
36
 
37
37
  // Utilities
38
38
  export { isItemEqual, getMaxStackSize } from './utils/isItemEqual'
39
+ export {
40
+ getInventoryDebugApi,
41
+ logInventoryDebug,
42
+ createInventoryDebugSession,
43
+ summarizeAction,
44
+ summarizeItem,
45
+ summarizeSlots,
46
+ summarizeWindowState,
47
+ getActionSlotIndexes,
48
+ sanitizeDebugValue,
49
+ } from './debug/inventoryDebug'
50
+ export type {
51
+ InventoryDebugApi,
52
+ InventoryDebugLogEntry,
53
+ InventoryDebugSession,
54
+ InventoryDebugState,
55
+ } from './debug/inventoryDebug'
39
56
 
40
57
  // Connector
41
58
  export { createMineflayerConnector } from './connector/mineflayer'