88 private array $inventories = [];
94 private array $networkIdToInventoryMap = [];
99 private array $complexSlotToInventoryMap = [];
101 private int $lastInventoryNetworkId = ContainerIds::FIRST;
102 private int $currentWindowType = WindowTypes::CONTAINER;
104 private int $clientSelectedHotbarSlot = -1;
107 private ObjectSet $containerOpenCallbacks;
109 private ?
int $pendingCloseWindowId =
null;
111 private ?\Closure $pendingOpenWindowCallback =
null;
113 private int $nextItemStackId = 1;
114 private ?
int $currentItemStackRequestId =
null;
116 private bool $fullSyncRequested =
false;
119 private array $enchantingTableOptions = [];
122 private int $nextEnchantingTableOptionId = 100000;
124 public function __construct(
128 $this->containerOpenCallbacks =
new ObjectSet();
129 $this->containerOpenCallbacks->add(self::createContainerOpen(...));
131 $this->add(ContainerIds::INVENTORY, $this->player->getInventory());
132 $this->add(ContainerIds::OFFHAND, $this->player->getOffHandInventory());
133 $this->add(ContainerIds::ARMOR, $this->player->getArmorInventory());
134 $this->addComplex(UIInventorySlotOffset::CURSOR, $this->player->getCursorInventory());
135 $this->addComplex(UIInventorySlotOffset::CRAFTING2X2_INPUT, $this->player->getCraftingGrid());
137 $this->player->getInventory()->getHeldItemIndexChangeListeners()->add($this->syncSelectedHotbarSlot(...));
140 private function associateIdWithInventory(
int $id,
Inventory $inventory) :
void{
141 $this->networkIdToInventoryMap[$id] = $inventory;
144 private function getNewWindowId() :
int{
145 $this->lastInventoryNetworkId = max(ContainerIds::FIRST, ($this->lastInventoryNetworkId + 1) % ContainerIds::LAST);
146 return $this->lastInventoryNetworkId;
149 private function add(
int $id,
Inventory $inventory) :
void{
150 if(isset($this->inventories[spl_object_id($inventory)])){
151 throw new \InvalidArgumentException(
"Inventory " . get_class($inventory) .
" is already tracked");
154 $this->associateIdWithInventory($id, $inventory);
157 private function addDynamic(
Inventory $inventory) :
int{
158 $id = $this->getNewWindowId();
159 $this->add($id, $inventory);
167 private function addComplex(array|
int $slotMap,
Inventory $inventory) :
void{
168 if(isset($this->inventories[spl_object_id($inventory)])){
169 throw new \InvalidArgumentException(
"Inventory " . get_class($inventory) .
" is already tracked");
176 foreach($complexSlotMap->getSlotMap() as $netSlot => $coreSlot){
177 $this->complexSlotToInventoryMap[$netSlot] = $complexSlotMap;
185 private function addComplexDynamic(array|
int $slotMap,
Inventory $inventory) :
int{
186 $this->addComplex($slotMap, $inventory);
187 $id = $this->getNewWindowId();
188 $this->associateIdWithInventory($id, $inventory);
192 private function remove(
int $id) :
void{
193 $inventory = $this->networkIdToInventoryMap[$id];
194 unset($this->networkIdToInventoryMap[$id]);
195 if($this->getWindowId($inventory) ===
null){
196 unset($this->inventories[spl_object_id($inventory)]);
197 foreach($this->complexSlotToInventoryMap as $netSlot => $entry){
198 if($entry->getInventory() === $inventory){
199 unset($this->complexSlotToInventoryMap[$netSlot]);
205 public function getWindowId(
Inventory $inventory) : ?
int{
206 return ($id = array_search($inventory, $this->networkIdToInventoryMap,
true)) !==
false ? $id :
null;
209 public function getCurrentWindowId() :
int{
210 return $this->lastInventoryNetworkId;
218 $entry = $this->complexSlotToInventoryMap[$netSlotId] ??
null;
222 $inventory = $entry->getInventory();
223 $coreSlotId = $entry->mapNetToCore($netSlotId);
224 return $coreSlotId !==
null && $inventory->
slotExists($coreSlotId) ? [$inventory, $coreSlotId] :
null;
226 $inventory = $this->networkIdToInventoryMap[$windowId] ??
null;
227 if($inventory !==
null && $inventory->
slotExists($netSlotId)){
228 return [$inventory, $netSlotId];
233 private function addPredictedSlotChangeInternal(Inventory $inventory,
int $slot, ItemStack $item) : void{
234 $this->inventories[spl_object_id($inventory)]->predictions[$slot] = $item;
237 public function addPredictedSlotChange(Inventory $inventory,
int $slot, Item $item) : void{
238 $typeConverter = $this->session->getTypeConverter();
239 $itemStack = $typeConverter->coreItemStackToNet($item);
240 $this->addPredictedSlotChangeInternal($inventory, $slot, $itemStack);
243 public function addTransactionPredictedSlotChanges(InventoryTransaction $tx) : void{
244 foreach($tx->getActions() as $action){
245 if($action instanceof SlotChangeAction){
247 $this->addPredictedSlotChange(
248 $action->getInventory(),
250 $action->getTargetItem()
261 foreach($networkInventoryActions as $action){
262 if($action->sourceType !== NetworkInventoryAction::SOURCE_CONTAINER){
268 if(match($action->windowId){
269 ContainerIds::INVENTORY, ContainerIds::OFFHAND, ContainerIds::ARMOR => false,
272 throw new PacketHandlingException(
"Legacy transactions cannot predict changes to inventory with ID " . $action->windowId);
274 $info = $this->locateWindowAndSlot($action->windowId, $action->inventorySlot);
279 [$inventory, $slot] = $info;
280 $this->addPredictedSlotChangeInternal($inventory, $slot, $action->newItem->getItemStack());
284 public function setCurrentItemStackRequestId(?
int $id) : void{
285 $this->currentItemStackRequestId = $id;
302 private function openWindowDeferred(\Closure $func) : void{
303 if($this->pendingCloseWindowId !== null){
304 $this->session->getLogger()->debug(
"Deferring opening of new window, waiting for close ack of window $this->pendingCloseWindowId");
305 $this->pendingOpenWindowCallback = $func;
315 private function createComplexSlotMapping(Inventory $inventory) : ?array{
318 $inventory instanceof AnvilInventory => UIInventorySlotOffset::ANVIL,
319 $inventory instanceof EnchantInventory => UIInventorySlotOffset::ENCHANTING_TABLE,
320 $inventory instanceof LoomInventory => UIInventorySlotOffset::LOOM,
321 $inventory instanceof StonecutterInventory => [UIInventorySlotOffset::STONE_CUTTER_INPUT => StonecutterInventory::SLOT_INPUT],
322 $inventory instanceof CraftingTableInventory => UIInventorySlotOffset::CRAFTING3X3_INPUT,
323 $inventory instanceof CartographyTableInventory => UIInventorySlotOffset::CARTOGRAPHY_TABLE,
324 $inventory instanceof SmithingTableInventory => UIInventorySlotOffset::SMITHING_TABLE,
329 public function onCurrentWindowChange(Inventory $inventory) : void{
330 $this->onCurrentWindowRemove();
332 $this->openWindowDeferred(
function() use ($inventory) :
void{
333 if(($slotMap = $this->createComplexSlotMapping($inventory)) !==
null){
334 $windowId = $this->addComplexDynamic($slotMap, $inventory);
336 $windowId = $this->addDynamic($inventory);
339 foreach($this->containerOpenCallbacks as $callback){
340 $pks = $callback($windowId, $inventory);
343 foreach($pks as $pk){
344 if($pk instanceof ContainerOpenPacket){
346 $windowType = $pk->windowType;
348 $this->session->sendDataPacket($pk);
350 $this->currentWindowType = $windowType ?? WindowTypes::CONTAINER;
351 $this->syncContents($inventory);
355 throw new \LogicException(
"Unsupported inventory type");
370 $blockPosition = BlockPosition::fromVector3($inv->getHolder());
371 $windowType = match(
true){
374 FurnaceType::FURNACE => WindowTypes::FURNACE,
375 FurnaceType::BLAST_FURNACE => WindowTypes::BLAST_FURNACE,
376 FurnaceType::SMOKER => WindowTypes::SMOKER,
377 FurnaceType::CAMPFIRE, FurnaceType::SOUL_CAMPFIRE =>
throw new \LogicException(
"Campfire inventory cannot be displayed to a player")
387 default => WindowTypes::CONTAINER
389 return [ContainerOpenPacket::blockInv($id, $windowType, $blockPosition)];
394 public function onClientOpenMainInventory() : void{
395 $this->onCurrentWindowRemove();
397 $this->openWindowDeferred(
function() :
void{
398 $windowId = $this->getNewWindowId();
399 $this->associateIdWithInventory($windowId, $this->player->getInventory());
400 $this->currentWindowType = WindowTypes::INVENTORY;
402 $this->session->sendDataPacket(ContainerOpenPacket::entityInv(
404 $this->currentWindowType,
405 $this->player->getId()
410 public function onCurrentWindowRemove() : void{
411 if(isset($this->networkIdToInventoryMap[$this->lastInventoryNetworkId])){
412 $this->
remove($this->lastInventoryNetworkId);
413 $this->session->sendDataPacket(ContainerClosePacket::create($this->lastInventoryNetworkId, $this->currentWindowType,
true));
414 if($this->pendingCloseWindowId !==
null){
415 throw new AssumptionFailedError(
"We should not have opened a new window while a window was waiting to be closed");
417 $this->pendingCloseWindowId = $this->lastInventoryNetworkId;
418 $this->enchantingTableOptions = [];
422 public function onClientRemoveWindow(
int $id) : void{
423 if(Binary::signByte($id) === ContainerIds::NONE){
429 $this->session->getLogger()->debug(
"Client rejected opening of a window, assuming it was $this->lastInventoryNetworkId");
430 $id = $this->lastInventoryNetworkId;
432 if($id === $this->lastInventoryNetworkId){
433 if(isset($this->networkIdToInventoryMap[$id]) && $id !== $this->pendingCloseWindowId){
435 $this->player->removeCurrentWindow();
438 $this->session->getLogger()->debug(
"Attempted to close inventory with network ID $id, but current is $this->lastInventoryNetworkId");
443 $this->session->sendDataPacket(ContainerClosePacket::create($id, $this->currentWindowType,
false));
445 if($this->pendingCloseWindowId === $id){
446 $this->pendingCloseWindowId =
null;
447 if($this->pendingOpenWindowCallback !==
null){
448 $this->session->getLogger()->debug(
"Opening deferred window after close ack of window $id");
449 ($this->pendingOpenWindowCallback)();
450 $this->pendingOpenWindowCallback =
null;
462 private function itemStackExtraDataEqual(ItemStack $left, ItemStack $right) : bool{
463 if($left->getRawExtraData() === $right->getRawExtraData()){
467 $typeConverter = $this->session->getTypeConverter();
468 $leftExtraData = $typeConverter->deserializeItemStackExtraData($left->getRawExtraData(), $left->getId());
469 $rightExtraData = $typeConverter->deserializeItemStackExtraData($right->getRawExtraData(), $right->getId());
471 $leftNbt = $leftExtraData->getNbt();
472 $rightNbt = $rightExtraData->getNbt();
474 $leftExtraData->getCanPlaceOn() === $rightExtraData->getCanPlaceOn() &&
475 $leftExtraData->getCanDestroy() === $rightExtraData->getCanDestroy() && (
476 $leftNbt === $rightNbt ||
477 ($leftNbt !==
null && $rightNbt !==
null && $leftNbt->equals($rightNbt))
481 private function itemStacksEqual(ItemStack $left, ItemStack $right) : bool{
483 $left->getId() === $right->getId() &&
484 $left->getMeta() === $right->getMeta() &&
485 $left->getBlockRuntimeId() === $right->getBlockRuntimeId() &&
486 $left->getCount() === $right->getCount() &&
487 $this->itemStackExtraDataEqual($left, $right);
490 public function onSlotChange(Inventory $inventory,
int $slot) : void{
491 $inventoryEntry = $this->inventories[spl_object_id($inventory)] ?? null;
492 if($inventoryEntry ===
null){
497 $currentItem = $this->session->getTypeConverter()->coreItemStackToNet($inventory->getItem($slot));
498 $clientSideItem = $inventoryEntry->predictions[$slot] ??
null;
499 if($clientSideItem ===
null || !$this->itemStacksEqual($currentItem, $clientSideItem)){
501 $this->trackItemStack($inventoryEntry, $slot, $currentItem,
null);
502 $inventoryEntry->pendingSyncs[$slot] = $currentItem;
505 $this->trackItemStack($inventoryEntry, $slot, $currentItem, $this->currentItemStackRequestId);
508 unset($inventoryEntry->predictions[$slot]);
511 private function sendInventorySlotPackets(
int $windowId,
int $netSlot, ItemStackWrapper $itemStackWrapper) : void{
520 if($itemStackWrapper->getStackId() !== 0){
521 $this->session->sendDataPacket(InventorySlotPacket::create(
524 new FullContainerName($this->lastInventoryNetworkId),
525 new ItemStackWrapper(0, ItemStack::null()),
526 new ItemStackWrapper(0, ItemStack::null())
530 $this->session->sendDataPacket(InventorySlotPacket::create(
533 new FullContainerName($this->lastInventoryNetworkId),
534 new ItemStackWrapper(0, ItemStack::null()),
542 private function sendInventoryContentPackets(
int $windowId, array $itemStackWrappers) : void{
551 $this->session->sendDataPacket(InventoryContentPacket::create(
553 array_fill_keys(array_keys($itemStackWrappers), new ItemStackWrapper(0, ItemStack::null())),
554 new FullContainerName($this->lastInventoryNetworkId),
555 new ItemStackWrapper(0, ItemStack::null())
558 $this->session->sendDataPacket(InventoryContentPacket::create($windowId, $itemStackWrappers,
new FullContainerName($this->lastInventoryNetworkId),
new ItemStackWrapper(0, ItemStack::null())));
561 public function syncSlot(Inventory $inventory,
int $slot, ItemStack $itemStack) : void{
562 $entry = $this->inventories[spl_object_id($inventory)] ?? null;
564 throw new \LogicException(
"Cannot sync an untracked inventory");
566 $itemStackInfo = $entry->itemStackInfos[$slot];
567 if($itemStackInfo ===
null){
568 throw new \LogicException(
"Cannot sync an untracked inventory slot");
570 if($entry->complexSlotMap !==
null){
571 $windowId = ContainerIds::UI;
572 $netSlot = $entry->complexSlotMap->mapCoreToNet($slot) ??
throw new AssumptionFailedError(
"We already have an ItemStackInfo, so this should not be null");
574 $windowId = $this->getWindowId($inventory) ??
throw new AssumptionFailedError(
"We already have an ItemStackInfo, so this should not be null");
578 $itemStackWrapper =
new ItemStackWrapper($itemStackInfo->getStackId(), $itemStack);
579 if($windowId === ContainerIds::OFFHAND){
585 $this->sendInventoryContentPackets($windowId, [$itemStackWrapper]);
587 $this->sendInventorySlotPackets($windowId, $netSlot, $itemStackWrapper);
589 unset($entry->predictions[$slot], $entry->pendingSyncs[$slot]);
592 public function syncContents(Inventory $inventory) : void{
593 $entry = $this->inventories[spl_object_id($inventory)] ?? null;
599 if($entry->complexSlotMap !==
null){
600 $windowId = ContainerIds::UI;
602 $windowId = $this->getWindowId($inventory);
604 if($windowId !==
null){
605 $entry->predictions = [];
606 $entry->pendingSyncs = [];
608 $typeConverter = $this->session->getTypeConverter();
609 foreach($inventory->getContents(
true) as $slot => $item){
610 $itemStack = $typeConverter->coreItemStackToNet($item);
611 $info = $this->trackItemStack($entry, $slot, $itemStack,
null);
612 $contents[] =
new ItemStackWrapper($info->getStackId(), $itemStack);
614 if($entry->complexSlotMap !==
null){
615 foreach($contents as $slotId => $info){
616 $packetSlot = $entry->complexSlotMap->mapCoreToNet($slotId) ??
null;
617 if($packetSlot ===
null){
620 $this->sendInventorySlotPackets($windowId, $packetSlot, $info);
623 $this->sendInventoryContentPackets($windowId, $contents);
628 public function syncAll() : void{
629 foreach($this->inventories as $entry){
630 $this->syncContents($entry->inventory);
634 public function requestSyncAll() : void{
635 $this->fullSyncRequested = true;
638 public function syncMismatchedPredictedSlotChanges() : void{
639 $typeConverter = $this->session->getTypeConverter();
640 foreach($this->inventories as $entry){
641 $inventory = $entry->inventory;
642 foreach($entry->predictions as $slot => $expectedItem){
643 if(!$inventory->slotExists($slot) || $entry->itemStackInfos[$slot] ===
null){
648 $this->session->getLogger()->debug(
"Detected prediction mismatch in inventory " . get_class($inventory) .
"#" . spl_object_id($inventory) .
" slot $slot");
649 $entry->pendingSyncs[$slot] = $typeConverter->coreItemStackToNet($inventory->getItem($slot));
652 $entry->predictions = [];
656 public function flushPendingUpdates() : void{
657 if($this->fullSyncRequested){
658 $this->fullSyncRequested =
false;
659 $this->session->getLogger()->debug(
"Full inventory sync requested, sending contents of " . count($this->inventories) .
" inventories");
662 foreach($this->inventories as $entry){
663 if(count($entry->pendingSyncs) === 0){
666 $inventory = $entry->inventory;
667 $this->session->getLogger()->debug(
"Syncing slots " . implode(
", ", array_keys($entry->pendingSyncs)) .
" in inventory " . get_class($inventory) .
"#" . spl_object_id($inventory));
668 foreach($entry->pendingSyncs as $slot => $itemStack){
669 $this->syncSlot($inventory, $slot, $itemStack);
671 $entry->pendingSyncs = [];
676 public function syncData(Inventory $inventory,
int $propertyId,
int $value) : void{
677 $windowId = $this->getWindowId($inventory);
678 if($windowId !==
null){
679 $this->session->sendDataPacket(ContainerSetDataPacket::create($windowId, $propertyId, $value));
683 public function onClientSelectHotbarSlot(
int $slot) : void{
684 $this->clientSelectedHotbarSlot = $slot;
687 public function syncSelectedHotbarSlot() : void{
688 $playerInventory = $this->player->getInventory();
689 $selected = $playerInventory->getHeldItemIndex();
690 if($selected !== $this->clientSelectedHotbarSlot){
691 $inventoryEntry = $this->inventories[spl_object_id($playerInventory)] ??
null;
692 if($inventoryEntry ===
null){
693 throw new AssumptionFailedError(
"Player inventory should always be tracked");
695 $itemStackInfo = $inventoryEntry->itemStackInfos[$selected] ??
null;
696 if($itemStackInfo ===
null){
697 throw new AssumptionFailedError(
"Untracked player inventory slot $selected");
700 $this->session->sendDataPacket(MobEquipmentPacket::create(
701 $this->player->getId(),
702 new ItemStackWrapper($itemStackInfo->getStackId(), $this->session->getTypeConverter()->coreItemStackToNet($playerInventory->getItemInHand())),
705 ContainerIds::INVENTORY
707 $this->clientSelectedHotbarSlot = $selected;
711 public function syncCreative() : void{
712 $this->session->sendDataPacket(CreativeInventoryCache::getInstance()->buildPacket($this->player->getCreativeInventory(), $this->session));
720 $protocolOptions = [];
722 foreach($options as $index => $option){
723 $optionId = $this->nextEnchantingTableOptionId++;
724 $this->enchantingTableOptions[$optionId] = $index;
726 $protocolEnchantments = array_map(
728 $option->getEnchantments()
732 $protocolOptions[] =
new ProtocolEnchantOption(
733 $option->getRequiredXpLevel(),
734 0, $protocolEnchantments,
737 $option->getDisplayName(),
742 $this->session->sendDataPacket(PlayerEnchantOptionsPacket::create($protocolOptions));
745 public function getEnchantingTableOptionIndex(
int $recipeId) : ?int{
746 return $this->enchantingTableOptions[$recipeId] ?? null;
749 private function newItemStackId() : int{
750 return $this->nextItemStackId++;
753 public function getItemStackInfo(Inventory $inventory,
int $slot) : ?ItemStackInfo{
754 $entry = $this->inventories[spl_object_id($inventory)] ?? null;
755 return $entry?->itemStackInfos[$slot] ??
null;
758 private function trackItemStack(InventoryManagerEntry $entry,
int $slotId, ItemStack $itemStack, ?
int $itemStackRequestId) : ItemStackInfo{
760 $info = new ItemStackInfo($itemStackRequestId, $itemStack->getId() === 0 ? 0 : $this->newItemStackId());
761 return $entry->itemStackInfos[$slotId] = $info;