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;