90 private array $entries = [];
96 private array $networkIdToWindowMap = [];
101 private array $complexSlotToWindowMap = [];
103 private int $lastWindowNetworkId = ContainerIds::FIRST;
104 private int $currentWindowType = WindowTypes::CONTAINER;
106 private int $clientSelectedHotbarSlot = -1;
109 private ObjectSet $containerOpenCallbacks;
111 private ?
int $pendingCloseWindowId =
null;
113 private ?\Closure $pendingOpenWindowCallback =
null;
115 private int $nextItemStackId = 1;
116 private ?
int $currentItemStackRequestId =
null;
118 private bool $fullSyncRequested =
false;
121 private array $enchantingTableOptions = [];
124 private int $nextEnchantingTableOptionId = 100000;
126 public function __construct(
130 $this->containerOpenCallbacks =
new ObjectSet();
131 $this->containerOpenCallbacks->add(self::createContainerOpen(...));
133 foreach($this->player->getPermanentWindows() as $window){
134 match($window->getType()){
135 PlayerInventoryWindow::TYPE_INVENTORY => $this->add(ContainerIds::INVENTORY, $window),
136 PlayerInventoryWindow::TYPE_OFFHAND => $this->add(ContainerIds::OFFHAND, $window),
137 PlayerInventoryWindow::TYPE_ARMOR => $this->add(ContainerIds::ARMOR, $window),
138 PlayerInventoryWindow::TYPE_CURSOR => $this->addComplex(UIInventorySlotOffset::CURSOR, $window),
139 PlayerInventoryWindow::TYPE_CRAFTING => $this->addComplex(UIInventorySlotOffset::CRAFTING2X2_INPUT, $window),
144 $this->player->getHotbar()->getSelectedIndexChangeListeners()->add($this->syncSelectedHotbarSlot(...));
147 private function associateIdWithInventory(
int $id,
InventoryWindow $window) :
void{
148 $this->networkIdToWindowMap[$id] = $window;
151 private function getNewWindowId() :
int{
152 $this->lastWindowNetworkId = max(ContainerIds::FIRST, ($this->lastWindowNetworkId + 1) % ContainerIds::LAST);
153 return $this->lastWindowNetworkId;
157 return $this->entries[spl_object_id($inventory)] ??
null;
161 return $this->getEntry($window->getInventory());
165 return $this->getEntry($inventory)?->window;
169 $k = spl_object_id($window->getInventory());
170 if(isset($this->entries[$k])){
171 throw new \InvalidArgumentException(
"Inventory " . get_class($window->getInventory()) .
" is already tracked (open in two different windows?)");
174 $window->getInventory()->getListeners()->add($this);
175 $this->associateIdWithInventory($id, $window);
179 $id = $this->getNewWindowId();
180 $this->add($id, $inventory);
188 private function addComplex(array|
int $slotMap,
InventoryWindow $window) :
void{
189 $k = spl_object_id($window->getInventory());
190 if(isset($this->entries[$k])){
191 throw new \InvalidArgumentException(
"Inventory " . get_class($window) .
" is already tracked");
198 $window->getInventory()->getListeners()->add($this);
199 foreach($complexSlotMap->getSlotMap() as $netSlot => $coreSlot){
200 $this->complexSlotToWindowMap[$netSlot] = $complexSlotMap;
208 private function addComplexDynamic(array|
int $slotMap,
InventoryWindow $inventory) :
int{
209 $this->addComplex($slotMap, $inventory);
210 $id = $this->getNewWindowId();
211 $this->associateIdWithInventory($id, $inventory);
215 private function remove(
int $id) :
void{
216 $window = $this->networkIdToWindowMap[$id];
217 $inventory = $window->getInventory();
218 unset($this->networkIdToWindowMap[$id]);
219 if($this->getWindowId($window) ===
null){
221 unset($this->entries[spl_object_id($inventory)]);
222 foreach($this->complexSlotToWindowMap as $netSlot => $entry){
223 if($entry->getWindow() === $window){
224 unset($this->complexSlotToWindowMap[$netSlot]);
231 return ($id = array_search($window, $this->networkIdToWindowMap,
true)) !==
false ? $id :
null;
234 public function getCurrentWindowId() :
int{
235 return $this->lastWindowNetworkId;
243 $entry = $this->complexSlotToWindowMap[$netSlotId] ??
null;
247 $window = $entry->getWindow();
248 $coreSlotId = $entry->mapNetToCore($netSlotId);
249 return $coreSlotId !==
null && $window->getInventory()->slotExists($coreSlotId) ? [$window, $coreSlotId] :
null;
251 $window = $this->networkIdToWindowMap[$windowId] ??
null;
252 if($window !==
null && $window->getInventory()->slotExists($netSlotId)){
253 return [$window, $netSlotId];
258 private function addPredictedSlotChangeInternal(InventoryWindow $window,
int $slot, ItemStack $item) : void{
260 $entry = $this->getEntryByWindow($window) ?? throw new
AssumptionFailedError(
"Assume this should never be null");
261 $entry->predictions[$slot] = $item;
264 public function addPredictedSlotChange(InventoryWindow $window,
int $slot, Item $item) : void{
265 $typeConverter = $this->session->getTypeConverter();
266 $itemStack = $typeConverter->coreItemStackToNet($item);
267 $this->addPredictedSlotChangeInternal($window, $slot, $itemStack);
270 public function addTransactionPredictedSlotChanges(InventoryTransaction $tx) : void{
271 foreach($tx->getActions() as $action){
272 if($action instanceof SlotChangeAction){
274 $this->addPredictedSlotChange(
275 $action->getInventoryWindow(),
277 $action->getTargetItem()
288 foreach($networkInventoryActions as $action){
289 if($action->sourceType !== NetworkInventoryAction::SOURCE_CONTAINER){
295 if(match($action->windowId){
296 ContainerIds::INVENTORY, ContainerIds::OFFHAND, ContainerIds::ARMOR => false,
299 throw new PacketHandlingException(
"Legacy transactions cannot predict changes to inventory with ID " . $action->windowId);
301 $info = $this->locateWindowAndSlot($action->windowId, $action->inventorySlot);
306 [$window, $slot] = $info;
307 $this->addPredictedSlotChangeInternal($window, $slot, $action->newItem->getItemStack());
311 public function setCurrentItemStackRequestId(?
int $id) : void{
312 $this->currentItemStackRequestId = $id;
329 private function openWindowDeferred(\Closure $func) : void{
330 if($this->pendingCloseWindowId !== null){
331 $this->session->getLogger()->debug(
"Deferring opening of new window, waiting for close ack of window $this->pendingCloseWindowId");
332 $this->pendingOpenWindowCallback = $func;
342 private function createComplexSlotMapping(InventoryWindow $inventory) : ?array{
345 $inventory instanceof AnvilInventoryWindow => UIInventorySlotOffset::ANVIL,
346 $inventory instanceof EnchantingTableInventoryWindow => UIInventorySlotOffset::ENCHANTING_TABLE,
347 $inventory instanceof LoomInventoryWindow => UIInventorySlotOffset::LOOM,
348 $inventory instanceof StonecutterInventoryWindow => [UIInventorySlotOffset::STONE_CUTTER_INPUT => StonecutterInventoryWindow::SLOT_INPUT],
349 $inventory instanceof CraftingTableInventoryWindow => UIInventorySlotOffset::CRAFTING3X3_INPUT,
350 $inventory instanceof CartographyTableInventoryWindow => UIInventorySlotOffset::CARTOGRAPHY_TABLE,
351 $inventory instanceof SmithingTableInventoryWindow => UIInventorySlotOffset::SMITHING_TABLE,
356 public function onCurrentWindowChange(InventoryWindow $window) : void{
357 $this->onCurrentWindowRemove();
359 $this->openWindowDeferred(
function() use ($window) :
void{
360 if(($slotMap = $this->createComplexSlotMapping($window)) !==
null){
361 $windowId = $this->addComplexDynamic($slotMap, $window);
363 $windowId = $this->addDynamic($window);
366 foreach($this->containerOpenCallbacks as $callback){
367 $pks = $callback($windowId, $window);
370 foreach($pks as $pk){
371 if($pk instanceof ContainerOpenPacket){
373 $windowType = $pk->windowType;
375 $this->session->sendDataPacket($pk);
377 $this->currentWindowType = $windowType ?? WindowTypes::CONTAINER;
378 $this->syncContents($window);
382 throw new \LogicException(
"Unsupported inventory type");
397 $blockPosition = BlockPosition::fromVector3($window->getHolder());
398 $windowType = match(
true){
401 FurnaceType::FURNACE => WindowTypes::FURNACE,
402 FurnaceType::BLAST_FURNACE => WindowTypes::BLAST_FURNACE,
403 FurnaceType::SMOKER => WindowTypes::SMOKER,
404 FurnaceType::CAMPFIRE, FurnaceType::SOUL_CAMPFIRE =>
throw new \LogicException(
"Campfire inventory cannot be displayed to a player")
414 default => WindowTypes::CONTAINER
416 return [ContainerOpenPacket::blockInv($id, $windowType, $blockPosition)];
421 public function onClientOpenMainInventory() : void{
422 $this->onCurrentWindowRemove();
424 $this->openWindowDeferred(
function() :
void{
425 $windowId = $this->getNewWindowId();
426 $window = $this->getInventoryWindow($this->player->getInventory()) ??
throw new AssumptionFailedError(
"This should never be null");
427 $this->associateIdWithInventory($windowId, $window);
428 $this->currentWindowType = WindowTypes::INVENTORY;
430 $this->session->sendDataPacket(ContainerOpenPacket::entityInv(
432 $this->currentWindowType,
433 $this->player->getId()
438 public function onCurrentWindowRemove() : void{
439 if(isset($this->networkIdToWindowMap[$this->lastWindowNetworkId])){
440 $this->
remove($this->lastWindowNetworkId);
441 $this->session->sendDataPacket(ContainerClosePacket::create($this->lastWindowNetworkId, $this->currentWindowType,
true));
442 if($this->pendingCloseWindowId !==
null){
443 throw new AssumptionFailedError(
"We should not have opened a new window while a window was waiting to be closed");
445 $this->pendingCloseWindowId = $this->lastWindowNetworkId;
446 $this->enchantingTableOptions = [];
450 public function onClientRemoveWindow(
int $id) : void{
451 if($id === $this->lastWindowNetworkId){
452 if(isset($this->networkIdToWindowMap[$id]) && $id !== $this->pendingCloseWindowId){
454 $this->player->removeCurrentWindow();
457 $this->session->getLogger()->debug(
"Attempted to close inventory with network ID $id, but current is $this->lastWindowNetworkId");
462 $this->session->sendDataPacket(ContainerClosePacket::create($id, $this->currentWindowType,
false));
464 if($this->pendingCloseWindowId === $id){
465 $this->pendingCloseWindowId =
null;
466 if($this->pendingOpenWindowCallback !==
null){
467 $this->session->getLogger()->debug(
"Opening deferred window after close ack of window $id");
468 ($this->pendingOpenWindowCallback)();
469 $this->pendingOpenWindowCallback =
null;
481 private function itemStackExtraDataEqual(ItemStack $left, ItemStack $right) : bool{
482 if($left->getRawExtraData() === $right->getRawExtraData()){
486 $typeConverter = $this->session->getTypeConverter();
487 $leftExtraData = $typeConverter->deserializeItemStackExtraData($left->getRawExtraData(), $left->getId());
488 $rightExtraData = $typeConverter->deserializeItemStackExtraData($right->getRawExtraData(), $right->getId());
490 $leftNbt = $leftExtraData->getNbt();
491 $rightNbt = $rightExtraData->getNbt();
493 $leftExtraData->getCanPlaceOn() === $rightExtraData->getCanPlaceOn() &&
494 $leftExtraData->getCanDestroy() === $rightExtraData->getCanDestroy() && (
495 $leftNbt === $rightNbt ||
496 ($leftNbt !==
null && $rightNbt !==
null && $leftNbt->equals($rightNbt))
500 private function itemStacksEqual(ItemStack $left, ItemStack $right) : bool{
502 $left->getId() === $right->getId() &&
503 $left->getMeta() === $right->getMeta() &&
504 $left->getBlockRuntimeId() === $right->getBlockRuntimeId() &&
505 $left->getCount() === $right->getCount() &&
506 $this->itemStackExtraDataEqual($left, $right);
509 public function onSlotChange(Inventory $inventory,
int $slot, Item $oldItem) : void{
510 $window = $this->getInventoryWindow($inventory);
511 if($window ===
null){
516 $this->requestSyncSlot($window, $slot);
519 public function requestSyncSlot(InventoryWindow $window,
int $slot) : void{
520 $inventoryEntry = $this->getEntryByWindow($window);
521 if($inventoryEntry ===
null){
527 $currentItem = $this->session->getTypeConverter()->coreItemStackToNet($window->getInventory()->getItem($slot));
528 $clientSideItem = $inventoryEntry->predictions[$slot] ??
null;
529 if($clientSideItem ===
null || !$this->itemStacksEqual($currentItem, $clientSideItem)){
531 $this->trackItemStack($inventoryEntry, $slot, $currentItem, null);
532 $inventoryEntry->pendingSyncs[$slot] = $currentItem;
535 $this->trackItemStack($inventoryEntry, $slot, $currentItem, $this->currentItemStackRequestId);
538 unset($inventoryEntry->predictions[$slot]);
541 private function sendInventorySlotPackets(
int $windowId,
int $netSlot, ItemStackWrapper $itemStackWrapper) : void{
550 if($itemStackWrapper->getStackId() !== 0){
551 $this->session->sendDataPacket(InventorySlotPacket::create(
554 new FullContainerName($this->lastWindowNetworkId),
555 new ItemStackWrapper(0, ItemStack::null()),
556 new ItemStackWrapper(0, ItemStack::null())
560 $this->session->sendDataPacket(InventorySlotPacket::create(
563 new FullContainerName($this->lastWindowNetworkId),
564 new ItemStackWrapper(0, ItemStack::null()),
572 private function sendInventoryContentPackets(
int $windowId, array $itemStackWrappers) : void{
581 $this->session->sendDataPacket(InventoryContentPacket::create(
583 array_fill_keys(array_keys($itemStackWrappers), new ItemStackWrapper(0, ItemStack::null())),
584 new FullContainerName($this->lastWindowNetworkId),
585 new ItemStackWrapper(0, ItemStack::null())
588 $this->session->sendDataPacket(InventoryContentPacket::create($windowId, $itemStackWrappers,
new FullContainerName($this->lastWindowNetworkId),
new ItemStackWrapper(0, ItemStack::null())));
591 private function syncSlot(InventoryWindow $window,
int $slot, ItemStack $itemStack) : void{
592 $entry = $this->getEntryByWindow($window) ?? throw new \LogicException(
"Cannot sync an untracked inventory");
593 $itemStackInfo = $entry->itemStackInfos[$slot];
594 if($itemStackInfo ===
null){
595 throw new \LogicException(
"Cannot sync an untracked inventory slot");
597 if($entry->complexSlotMap !==
null){
598 $windowId = ContainerIds::UI;
599 $netSlot = $entry->complexSlotMap->mapCoreToNet($slot) ?? throw new AssumptionFailedError(
"We already have an ItemStackInfo, so this should not be null");
601 $windowId = $this->getWindowId($window) ?? throw new AssumptionFailedError(
"We already have an ItemStackInfo, so this should not be null");
605 $itemStackWrapper =
new ItemStackWrapper($itemStackInfo->getStackId(), $itemStack);
606 if($windowId === ContainerIds::OFFHAND){
612 $this->sendInventoryContentPackets($windowId, [$itemStackWrapper]);
614 $this->sendInventorySlotPackets($windowId, $netSlot, $itemStackWrapper);
616 unset($entry->predictions[$slot], $entry->pendingSyncs[$slot]);
622 $window = $this->getInventoryWindow($inventory);
623 if($window !==
null){
624 $this->syncContents($window);
628 private function syncContents(InventoryWindow $window) : void{
629 $entry = $this->getEntryByWindow($window);
635 if($entry->complexSlotMap !==
null){
636 $windowId = ContainerIds::UI;
638 $windowId = $this->getWindowId($window);
640 if($windowId !==
null){
641 $entry->predictions = [];
642 $entry->pendingSyncs = [];
644 $typeConverter = $this->session->getTypeConverter();
645 foreach($window->getInventory()->getContents(true) as $slot => $item){
646 $itemStack = $typeConverter->coreItemStackToNet($item);
647 $info = $this->trackItemStack($entry, $slot, $itemStack, null);
648 $contents[] = new ItemStackWrapper($info->getStackId(), $itemStack);
650 if($entry->complexSlotMap !==
null){
651 foreach($contents as $slotId => $info){
652 $packetSlot = $entry->complexSlotMap->mapCoreToNet($slotId) ?? null;
653 if($packetSlot === null){
656 $this->sendInventorySlotPackets($windowId, $packetSlot, $info);
659 $this->sendInventoryContentPackets($windowId, $contents);
664 public function syncAll() : void{
665 foreach($this->entries as $entry){
666 $this->syncContents($entry->window);
670 public function requestSyncAll() : void{
671 $this->fullSyncRequested = true;
674 public function syncMismatchedPredictedSlotChanges() : void{
675 $typeConverter = $this->session->getTypeConverter();
676 foreach($this->entries as $entry){
677 $inventory = $entry->window->getInventory();
678 foreach($entry->predictions as $slot => $expectedItem){
679 if(!$inventory->slotExists($slot) || $entry->itemStackInfos[$slot] ===
null){
684 $this->session->getLogger()->debug(
"Detected prediction mismatch in inventory " . get_class($inventory) .
"#" . spl_object_id($inventory) .
" slot $slot");
685 $entry->pendingSyncs[$slot] = $typeConverter->coreItemStackToNet($inventory->getItem($slot));
688 $entry->predictions = [];
692 public function flushPendingUpdates() : void{
693 if($this->fullSyncRequested){
694 $this->fullSyncRequested =
false;
695 $this->session->getLogger()->debug(
"Full inventory sync requested, sending contents of " . count($this->entries) .
" inventories");
698 foreach($this->entries as $entry){
699 if(count($entry->pendingSyncs) === 0){
702 $inventory = $entry->window;
703 $this->session->getLogger()->debug(
"Syncing slots " . implode(
", ", array_keys($entry->pendingSyncs)) .
" in inventory " . get_class($inventory) .
"#" . spl_object_id($inventory));
704 foreach($entry->pendingSyncs as $slot => $itemStack){
705 $this->syncSlot($inventory, $slot, $itemStack);
707 $entry->pendingSyncs = [];
712 public function syncData(Inventory $inventory,
int $propertyId,
int $value) : void{
715 $window = $this->getInventoryWindow($inventory);
716 if($window ===
null){
719 $windowId = $this->getWindowId($window);
720 if($windowId !==
null){
721 $this->session->sendDataPacket(ContainerSetDataPacket::create($windowId, $propertyId, $value));
725 public function onClientSelectHotbarSlot(
int $slot) : void{
726 $this->clientSelectedHotbarSlot = $slot;
729 public function syncSelectedHotbarSlot() : void{
730 $playerInventory = $this->player->getInventory();
731 $selected = $this->player->getHotbar()->getSelectedIndex();
732 if($selected !== $this->clientSelectedHotbarSlot){
733 $inventoryEntry = $this->getEntry($playerInventory) ??
throw new AssumptionFailedError(
"Player inventory should always be tracked");
734 $itemStackInfo = $inventoryEntry->itemStackInfos[$selected] ??
null;
735 if($itemStackInfo ===
null){
736 throw new AssumptionFailedError(
"Untracked player inventory slot $selected");
739 $this->session->sendDataPacket(MobEquipmentPacket::create(
740 $this->player->getId(),
741 new ItemStackWrapper($itemStackInfo->getStackId(), $this->session->getTypeConverter()->coreItemStackToNet($playerInventory->getItem($selected))),
744 ContainerIds::INVENTORY
746 $this->clientSelectedHotbarSlot = $selected;
750 public function syncCreative() : void{
751 $this->session->sendDataPacket(CreativeInventoryCache::getInstance()->buildPacket($this->player->getCreativeInventory(), $this->session));
759 $protocolOptions = [];
761 foreach($options as $index => $option){
762 $optionId = $this->nextEnchantingTableOptionId++;
763 $this->enchantingTableOptions[$optionId] = $index;
765 $protocolEnchantments = array_map(
767 $option->getEnchantments()
771 $protocolOptions[] =
new ProtocolEnchantOption(
772 $option->getRequiredXpLevel(),
773 0, $protocolEnchantments,
776 $option->getDisplayName(),
781 $this->session->sendDataPacket(PlayerEnchantOptionsPacket::create($protocolOptions));
784 public function getEnchantingTableOptionIndex(
int $recipeId) : ?int{
785 return $this->enchantingTableOptions[$recipeId] ?? null;
788 private function newItemStackId() : int{
789 return $this->nextItemStackId++;
792 public function getItemStackInfo(InventoryWindow $window,
int $slot) : ?ItemStackInfo{
793 return $this->getEntryByWindow($window)?->itemStackInfos[$slot] ?? null;
796 private function trackItemStack(InventoryManagerEntry $entry,
int $slotId, ItemStack $itemStack, ?
int $itemStackRequestId) : ItemStackInfo{
798 $info = new ItemStackInfo($itemStackRequestId, $itemStack->getId() === 0 ? 0 : $this->newItemStackId());
799 return $entry->itemStackInfos[$slotId] = $info;