PocketMine-MP 5.35.1 git-e32e836dad793a3a3c8ddd8927c00e112b1e576a
Loading...
Searching...
No Matches
InventoryManager.php
1<?php
2
3/*
4 *
5 * ____ _ _ __ __ _ __ __ ____
6 * | _ \ ___ ___| | _____| |_| \/ (_)_ __ ___ | \/ | _ \
7 * | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
8 * | __/ (_) | (__| < __/ |_| | | | | | | | __/_____| | | | __/
9 * |_| \___/ \___|_|\_\___|\__|_| |_|_|_| |_|\___| |_| |_|_|
10 *
11 * This program is free software: you can redistribute it and/or modify
12 * it under the terms of the GNU Lesser General Public License as published by
13 * the Free Software Foundation, either version 3 of the License, or
14 * (at your option) any later version.
15 *
16 * @author PocketMine Team
17 * @link http://www.pocketmine.net/
18 *
19 *
20 */
21
22declare(strict_types=1);
23
24namespace pocketmine\network\mcpe;
25
37use pocketmine\crafting\FurnaceType;
72use function array_fill_keys;
73use function array_keys;
74use function array_map;
75use function array_search;
76use function count;
77use function get_class;
78use function implode;
79use function is_int;
80use function max;
81use function spl_object_id;
82
91 private array $entries = [];
92
97 private array $networkIdToWindowMap = [];
102 private array $complexSlotToWindowMap = [];
103
104 private int $lastWindowNetworkId = ContainerIds::FIRST;
105 private int $currentWindowType = WindowTypes::CONTAINER;
106
107 private int $clientSelectedHotbarSlot = -1;
108
110 private ObjectSet $containerOpenCallbacks;
111
112 private ?int $pendingCloseWindowId = null;
114 private ?\Closure $pendingOpenWindowCallback = null;
115
116 private int $nextItemStackId = 1;
117 private ?int $currentItemStackRequestId = null;
118
119 private bool $fullSyncRequested = false;
120
122 private array $enchantingTableOptions = [];
123 //TODO: this should be based on the total number of crafting recipes - if there are ever 100k recipes, this will
124 //conflict with regular recipes
125 private int $nextEnchantingTableOptionId = 100000;
126
127 public function __construct(
128 private Player $player,
129 private NetworkSession $session
130 ){
131 $this->containerOpenCallbacks = new ObjectSet();
132 $this->containerOpenCallbacks->add(self::createContainerOpen(...));
133
134 foreach($this->player->getPermanentWindows() as $window){
135 match($window->getType()){
136 PlayerInventoryWindow::TYPE_INVENTORY => $this->add(ContainerIds::INVENTORY, $window),
137 PlayerInventoryWindow::TYPE_OFFHAND => $this->add(ContainerIds::OFFHAND, $window),
138 PlayerInventoryWindow::TYPE_ARMOR => $this->add(ContainerIds::ARMOR, $window),
139 PlayerInventoryWindow::TYPE_CURSOR => $this->addComplex(UIInventorySlotOffset::CURSOR, $window),
140 PlayerInventoryWindow::TYPE_CRAFTING => $this->addComplex(UIInventorySlotOffset::CRAFTING2X2_INPUT, $window),
141 default => throw new AssumptionFailedError("Unknown permanent window type " . $window->getType())
142 };
143 }
144
145 $this->player->getHotbar()->getSelectedIndexChangeListeners()->add($this->syncSelectedHotbarSlot(...));
146 }
147
148 private function associateIdWithInventory(int $id, InventoryWindow $window) : void{
149 $this->networkIdToWindowMap[$id] = $window;
150 }
151
152 private function getNewWindowId() : int{
153 $this->lastWindowNetworkId = max(ContainerIds::FIRST, ($this->lastWindowNetworkId + 1) % ContainerIds::LAST);
154 return $this->lastWindowNetworkId;
155 }
156
157 private function getEntry(Inventory $inventory) : ?InventoryManagerEntry{
158 return $this->entries[spl_object_id($inventory)] ?? null;
159 }
160
161 private function getEntryByWindow(InventoryWindow $window) : ?InventoryManagerEntry{
162 return $this->getEntry($window->getInventory());
163 }
164
165 public function getInventoryWindow(Inventory $inventory) : ?InventoryWindow{
166 return $this->getEntry($inventory)?->window;
167 }
168
169 private function add(int $id, InventoryWindow $window) : void{
170 $k = spl_object_id($window->getInventory());
171 if(isset($this->entries[$k])){
172 throw new \InvalidArgumentException("Inventory " . get_class($window->getInventory()) . " is already tracked (open in two different windows?)");
173 }
174 $this->entries[$k] = new InventoryManagerEntry($window);
175 $window->getInventory()->getListeners()->add($this);
176 $this->associateIdWithInventory($id, $window);
177 }
178
179 private function addDynamic(InventoryWindow $inventory) : int{
180 $id = $this->getNewWindowId();
181 $this->add($id, $inventory);
182 return $id;
183 }
184
189 private function addComplex(array|int $slotMap, InventoryWindow $window) : void{
190 $k = spl_object_id($window->getInventory());
191 if(isset($this->entries[$k])){
192 throw new \InvalidArgumentException("Inventory " . get_class($window) . " is already tracked");
193 }
194 $complexSlotMap = new ComplexWindowMapEntry($window, is_int($slotMap) ? [$slotMap => 0] : $slotMap);
195 $this->entries[$k] = new InventoryManagerEntry(
196 $window,
197 $complexSlotMap
198 );
199 $window->getInventory()->getListeners()->add($this);
200 foreach($complexSlotMap->getSlotMap() as $netSlot => $coreSlot){
201 $this->complexSlotToWindowMap[$netSlot] = $complexSlotMap;
202 }
203 }
204
209 private function addComplexDynamic(array|int $slotMap, InventoryWindow $inventory) : int{
210 $this->addComplex($slotMap, $inventory);
211 $id = $this->getNewWindowId();
212 $this->associateIdWithInventory($id, $inventory);
213 return $id;
214 }
215
216 private function remove(int $id) : void{
217 $window = $this->networkIdToWindowMap[$id];
218 $inventory = $window->getInventory();
219 unset($this->networkIdToWindowMap[$id]);
220 if($this->getWindowId($window) === null){
221 $inventory->getListeners()->remove($this);
222 unset($this->entries[spl_object_id($inventory)]);
223 foreach($this->complexSlotToWindowMap as $netSlot => $entry){
224 if($entry->getWindow() === $window){
225 unset($this->complexSlotToWindowMap[$netSlot]);
226 }
227 }
228 }
229 }
230
231 public function getWindowId(InventoryWindow $window) : ?int{
232 return ($id = array_search($window, $this->networkIdToWindowMap, true)) !== false ? $id : null;
233 }
234
235 public function getCurrentWindowId() : int{
236 return $this->lastWindowNetworkId;
237 }
238
242 public function locateWindowAndSlot(int $windowId, int $netSlotId) : ?array{
243 if($windowId === ContainerIds::UI){
244 $entry = $this->complexSlotToWindowMap[$netSlotId] ?? null;
245 if($entry === null){
246 return null;
247 }
248 $window = $entry->getWindow();
249 $coreSlotId = $entry->mapNetToCore($netSlotId);
250 return $coreSlotId !== null && $window->getInventory()->slotExists($coreSlotId) ? [$window, $coreSlotId] : null;
251 }
252 $window = $this->networkIdToWindowMap[$windowId] ?? null;
253 if($window !== null && $window->getInventory()->slotExists($netSlotId)){
254 return [$window, $netSlotId];
255 }
256 return null;
257 }
258
259 private function addPredictedSlotChangeInternal(InventoryWindow $window, int $slot, ItemStack $item) : void{
260 //TODO: does this need a null check?
261 $entry = $this->getEntryByWindow($window) ?? throw new AssumptionFailedError("Assume this should never be null");
262 $entry->predictions[$slot] = $item;
263 }
264
265 public function addPredictedSlotChange(InventoryWindow $window, int $slot, Item $item) : void{
266 $typeConverter = $this->session->getTypeConverter();
267 $itemStack = $typeConverter->coreItemStackToNet($item);
268 $this->addPredictedSlotChangeInternal($window, $slot, $itemStack);
269 }
270
271 public function addTransactionPredictedSlotChanges(InventoryTransaction $tx) : void{
272 foreach($tx->getActions() as $action){
273 if($action instanceof SlotChangeAction){
274 //TODO: ItemStackRequestExecutor can probably build these predictions with much lower overhead
275 $this->addPredictedSlotChange(
276 $action->getInventoryWindow(),
277 $action->getSlot(),
278 $action->getTargetItem()
279 );
280 }
281 }
282 }
283
288 public function addRawPredictedSlotChanges(array $networkInventoryActions) : void{
289 foreach($networkInventoryActions as $action){
290 if($action->sourceType !== NetworkInventoryAction::SOURCE_CONTAINER){
291 continue;
292 }
293
294 //legacy transactions should not modify or predict anything other than these inventories, since these are
295 //the only ones accessible when not in-game (ItemStackRequest is used for everything else)
296 if(match($action->windowId){
297 ContainerIds::INVENTORY, ContainerIds::OFFHAND, ContainerIds::ARMOR => false,
298 default => true
299 }){
300 throw new PacketHandlingException("Legacy transactions cannot predict changes to inventory with ID " . $action->windowId);
301 }
302 $info = $this->locateWindowAndSlot($action->windowId, $action->inventorySlot);
303 if($info === null){
304 continue;
305 }
306
307 [$window, $slot] = $info;
308 $this->addPredictedSlotChangeInternal($window, $slot, $action->newItem->getItemStack());
309 }
310 }
311
312 public function setCurrentItemStackRequestId(?int $id) : void{
313 $this->currentItemStackRequestId = $id;
314 }
315
330 private function openWindowDeferred(\Closure $func) : void{
331 if($this->pendingCloseWindowId !== null){
332 $this->session->getLogger()->debug("Deferring opening of new window, waiting for close ack of window $this->pendingCloseWindowId");
333 $this->pendingOpenWindowCallback = $func;
334 }else{
335 $func();
336 }
337 }
338
343 private function createComplexSlotMapping(InventoryWindow $inventory) : ?array{
344 //TODO: make this dynamic so plugins can add mappings for stuff not implemented by PM
345 return match(true){
346 $inventory instanceof AnvilInventoryWindow => UIInventorySlotOffset::ANVIL,
347 $inventory instanceof EnchantingTableInventoryWindow => UIInventorySlotOffset::ENCHANTING_TABLE,
348 $inventory instanceof LoomInventoryWindow => UIInventorySlotOffset::LOOM,
349 $inventory instanceof StonecutterInventoryWindow => [UIInventorySlotOffset::STONE_CUTTER_INPUT => StonecutterInventoryWindow::SLOT_INPUT],
350 $inventory instanceof CraftingTableInventoryWindow => UIInventorySlotOffset::CRAFTING3X3_INPUT,
351 $inventory instanceof CartographyTableInventoryWindow => UIInventorySlotOffset::CARTOGRAPHY_TABLE,
352 $inventory instanceof SmithingTableInventoryWindow => UIInventorySlotOffset::SMITHING_TABLE,
353 default => null,
354 };
355 }
356
357 public function onCurrentWindowChange(InventoryWindow $window) : void{
358 $this->onCurrentWindowRemove();
359
360 $this->openWindowDeferred(function() use ($window) : void{
361 if(($slotMap = $this->createComplexSlotMapping($window)) !== null){
362 $windowId = $this->addComplexDynamic($slotMap, $window);
363 }else{
364 $windowId = $this->addDynamic($window);
365 }
366
367 foreach($this->containerOpenCallbacks as $callback){
368 $pks = $callback($windowId, $window);
369 if($pks !== null){
370 $windowType = null;
371 foreach($pks as $pk){
372 if($pk instanceof ContainerOpenPacket){
373 //workaround useless bullshit in 1.21 - ContainerClose requires a type now for some reason
374 $windowType = $pk->windowType;
375 }
376 $this->session->sendDataPacket($pk);
377 }
378 $this->currentWindowType = $windowType ?? WindowTypes::CONTAINER;
379 $this->syncContents($window);
380 return;
381 }
382 }
383 throw new \LogicException("Unsupported inventory type");
384 });
385 }
386
388 public function getContainerOpenCallbacks() : ObjectSet{ return $this->containerOpenCallbacks; }
389
394 protected static function createContainerOpen(int $id, InventoryWindow $window) : ?array{
395 //TODO: we should be using some kind of tagging system to identify the types. Instanceof is flaky especially
396 //if the class isn't final, not to mention being inflexible.
397 if($window instanceof BlockInventoryWindow){
398 $blockPosition = BlockPosition::fromVector3($window->getHolder());
399 $windowType = match(true){
400 $window instanceof LoomInventoryWindow => WindowTypes::LOOM,
401 $window instanceof FurnaceInventoryWindow => match($window->getFurnaceType()){
402 FurnaceType::FURNACE => WindowTypes::FURNACE,
403 FurnaceType::BLAST_FURNACE => WindowTypes::BLAST_FURNACE,
404 FurnaceType::SMOKER => WindowTypes::SMOKER,
405 FurnaceType::CAMPFIRE, FurnaceType::SOUL_CAMPFIRE => throw new \LogicException("Campfire inventory cannot be displayed to a player")
406 },
407 $window instanceof EnchantingTableInventoryWindow => WindowTypes::ENCHANTMENT,
408 $window instanceof BrewingStandInventoryWindow => WindowTypes::BREWING_STAND,
409 $window instanceof AnvilInventoryWindow => WindowTypes::ANVIL,
410 $window instanceof HopperInventoryWindow => WindowTypes::HOPPER,
411 $window instanceof CraftingTableInventoryWindow => WindowTypes::WORKBENCH,
412 $window instanceof StonecutterInventoryWindow => WindowTypes::STONECUTTER,
413 $window instanceof CartographyTableInventoryWindow => WindowTypes::CARTOGRAPHY,
414 $window instanceof SmithingTableInventoryWindow => WindowTypes::SMITHING_TABLE,
415 default => WindowTypes::CONTAINER
416 };
417 return [ContainerOpenPacket::blockInv($id, $windowType, $blockPosition)];
418 }
419 return null;
420 }
421
422 public function onClientOpenMainInventory() : void{
423 $this->onCurrentWindowRemove();
424
425 $this->openWindowDeferred(function() : void{
426 $windowId = $this->getNewWindowId();
427 $window = $this->getInventoryWindow($this->player->getInventory()) ?? throw new AssumptionFailedError("This should never be null");
428 $this->associateIdWithInventory($windowId, $window);
429 $this->currentWindowType = WindowTypes::INVENTORY;
430
431 $this->session->sendDataPacket(ContainerOpenPacket::entityInv(
432 $windowId,
433 $this->currentWindowType,
434 $this->player->getId()
435 ));
436 });
437 }
438
439 public function onCurrentWindowRemove() : void{
440 if(isset($this->networkIdToWindowMap[$this->lastWindowNetworkId])){
441 $this->remove($this->lastWindowNetworkId);
442 $this->session->sendDataPacket(ContainerClosePacket::create($this->lastWindowNetworkId, $this->currentWindowType, true));
443 if($this->pendingCloseWindowId !== null){
444 throw new AssumptionFailedError("We should not have opened a new window while a window was waiting to be closed");
445 }
446 $this->pendingCloseWindowId = $this->lastWindowNetworkId;
447 $this->enchantingTableOptions = [];
448 }
449 }
450
451 public function onClientRemoveWindow(int $id) : void{
452 if(Binary::signByte($id) === ContainerIds::NONE){ //TODO: REMOVE signByte() once BedrockProtocol + ext-encoding are implemented
453 //TODO: HACK! Since 1.21.100 (and probably earlier), the client will send -1 to close windows that it can't
454 //view for some reason, e.g. if the chat window was already open. This is pretty awkward, since it means
455 //that we can only assume it refers to the most recently sent window, and if we don't handle it,
456 //InventoryManager will never get the green light to send subsequent windows, which breaks inventory UIs.
457 //Fortunately, we already wait for close acks anyway, so the window ID is technically useless...?
458 $this->session->getLogger()->debug("Client rejected opening of a window, assuming it was $this->lastWindowNetworkId");
459 $id = $this->lastWindowNetworkId;
460 }
461 if($id === $this->lastWindowNetworkId){
462 if(isset($this->networkIdToWindowMap[$id]) && $id !== $this->pendingCloseWindowId){
463 $this->remove($id);
464 $this->player->removeCurrentWindow();
465 }
466 }else{
467 $this->session->getLogger()->debug("Attempted to close inventory with network ID $id, but current is $this->lastWindowNetworkId");
468 }
469
470 //Always send this, even if no window matches. If we told the client to close a window, it will behave as if it
471 //initiated the close and expect an ack.
472 $this->session->sendDataPacket(ContainerClosePacket::create($id, $this->currentWindowType, false));
473
474 if($this->pendingCloseWindowId === $id){
475 $this->pendingCloseWindowId = null;
476 if($this->pendingOpenWindowCallback !== null){
477 $this->session->getLogger()->debug("Opening deferred window after close ack of window $id");
478 ($this->pendingOpenWindowCallback)();
479 $this->pendingOpenWindowCallback = null;
480 }
481 }
482 }
483
491 private function itemStackExtraDataEqual(ItemStack $left, ItemStack $right) : bool{
492 if($left->getRawExtraData() === $right->getRawExtraData()){
493 return true;
494 }
495
496 $typeConverter = $this->session->getTypeConverter();
497 $leftExtraData = $typeConverter->deserializeItemStackExtraData($left->getRawExtraData(), $left->getId());
498 $rightExtraData = $typeConverter->deserializeItemStackExtraData($right->getRawExtraData(), $right->getId());
499
500 $leftNbt = $leftExtraData->getNbt();
501 $rightNbt = $rightExtraData->getNbt();
502 return
503 $leftExtraData->getCanPlaceOn() === $rightExtraData->getCanPlaceOn() &&
504 $leftExtraData->getCanDestroy() === $rightExtraData->getCanDestroy() && (
505 $leftNbt === $rightNbt || //this covers null === null and fast object identity
506 ($leftNbt !== null && $rightNbt !== null && $leftNbt->equals($rightNbt))
507 );
508 }
509
510 private function itemStacksEqual(ItemStack $left, ItemStack $right) : bool{
511 return
512 $left->getId() === $right->getId() &&
513 $left->getMeta() === $right->getMeta() &&
514 $left->getBlockRuntimeId() === $right->getBlockRuntimeId() &&
515 $left->getCount() === $right->getCount() &&
516 $this->itemStackExtraDataEqual($left, $right);
517 }
518
519 public function onSlotChange(Inventory $inventory, int $slot, Item $oldItem) : void{
520 $window = $this->getInventoryWindow($inventory);
521 if($window === null){
522 //this can happen when an inventory changed during InventoryCloseEvent, or when a temporary inventory
523 //is cleared before removal.
524 return;
525 }
526 $this->requestSyncSlot($window, $slot);
527 }
528
529 public function requestSyncSlot(InventoryWindow $window, int $slot) : void{
530 $inventoryEntry = $this->getEntryByWindow($window);
531 if($inventoryEntry === null){
532 //this can happen when an inventory changed during InventoryCloseEvent, or when a temporary inventory
533 //is cleared before removal.
534 return;
535 }
536
537 $currentItem = $this->session->getTypeConverter()->coreItemStackToNet($window->getInventory()->getItem($slot));
538 $clientSideItem = $inventoryEntry->predictions[$slot] ?? null;
539 if($clientSideItem === null || !$this->itemStacksEqual($currentItem, $clientSideItem)){
540 //no prediction or incorrect - do not associate this with the currently active itemstack request
541 $this->trackItemStack($inventoryEntry, $slot, $currentItem, null);
542 $inventoryEntry->pendingSyncs[$slot] = $currentItem;
543 }else{
544 //correctly predicted - associate the change with the currently active itemstack request
545 $this->trackItemStack($inventoryEntry, $slot, $currentItem, $this->currentItemStackRequestId);
546 }
547
548 unset($inventoryEntry->predictions[$slot]);
549 }
550
551 private function sendInventorySlotPackets(int $windowId, int $netSlot, ItemStackWrapper $itemStackWrapper) : void{
552 /*
553 * TODO: HACK!
554 * As of 1.20.12, the client ignores change of itemstackID in some cases when the old item == the new item.
555 * Notably, this happens with armor, offhand and enchanting tables, but not with main inventory.
556 * While we could track the items previously sent to the client, that's a waste of memory and would
557 * cost performance. Instead, clear the slot(s) first, then send the new item(s).
558 * The network cost of doing this is fortunately minimal, as an air itemstack is only 1 byte.
559 */
560 if($itemStackWrapper->getStackId() !== 0){
561 $this->session->sendDataPacket(InventorySlotPacket::create(
562 $windowId,
563 $netSlot,
564 new FullContainerName($this->lastWindowNetworkId),
565 new ItemStackWrapper(0, ItemStack::null()),
566 new ItemStackWrapper(0, ItemStack::null())
567 ));
568 }
569 //now send the real contents
570 $this->session->sendDataPacket(InventorySlotPacket::create(
571 $windowId,
572 $netSlot,
573 new FullContainerName($this->lastWindowNetworkId),
574 new ItemStackWrapper(0, ItemStack::null()),
575 $itemStackWrapper
576 ));
577 }
578
582 private function sendInventoryContentPackets(int $windowId, array $itemStackWrappers) : void{
583 /*
584 * TODO: HACK!
585 * As of 1.20.12, the client ignores change of itemstackID in some cases when the old item == the new item.
586 * Notably, this happens with armor, offhand and enchanting tables, but not with main inventory.
587 * While we could track the items previously sent to the client, that's a waste of memory and would
588 * cost performance. Instead, clear the slot(s) first, then send the new item(s).
589 * The network cost of doing this is fortunately minimal, as an air itemstack is only 1 byte.
590 */
591 $this->session->sendDataPacket(InventoryContentPacket::create(
592 $windowId,
593 array_fill_keys(array_keys($itemStackWrappers), new ItemStackWrapper(0, ItemStack::null())),
594 new FullContainerName($this->lastWindowNetworkId),
595 new ItemStackWrapper(0, ItemStack::null())
596 ));
597 //now send the real contents
598 $this->session->sendDataPacket(InventoryContentPacket::create($windowId, $itemStackWrappers, new FullContainerName($this->lastWindowNetworkId), new ItemStackWrapper(0, ItemStack::null())));
599 }
600
601 private function syncSlot(InventoryWindow $window, int $slot, ItemStack $itemStack) : void{
602 $entry = $this->getEntryByWindow($window) ?? throw new \LogicException("Cannot sync an untracked inventory");
603 $itemStackInfo = $entry->itemStackInfos[$slot];
604 if($itemStackInfo === null){
605 throw new \LogicException("Cannot sync an untracked inventory slot");
606 }
607 if($entry->complexSlotMap !== null){
608 $windowId = ContainerIds::UI;
609 $netSlot = $entry->complexSlotMap->mapCoreToNet($slot) ?? throw new AssumptionFailedError("We already have an ItemStackInfo, so this should not be null");
610 }else{
611 $windowId = $this->getWindowId($window) ?? throw new AssumptionFailedError("We already have an ItemStackInfo, so this should not be null");
612 $netSlot = $slot;
613 }
614
615 $itemStackWrapper = new ItemStackWrapper($itemStackInfo->getStackId(), $itemStack);
616 if($windowId === ContainerIds::OFFHAND){
617 //TODO: HACK!
618 //The client may sometimes ignore the InventorySlotPacket for the offhand slot.
619 //This can cause a lot of problems (totems, arrows, and more...).
620 //The workaround is to send an InventoryContentPacket instead
621 //BDS (Bedrock Dedicated Server) also seems to work this way.
622 $this->sendInventoryContentPackets($windowId, [$itemStackWrapper]);
623 }else{
624 $this->sendInventorySlotPackets($windowId, $netSlot, $itemStackWrapper);
625 }
626 unset($entry->predictions[$slot], $entry->pendingSyncs[$slot]);
627 }
628
629 public function onContentChange(Inventory $inventory, array $oldContents) : void{
630 //this can be null when an inventory changed during InventoryCloseEvent, or when a temporary inventory
631 //is cleared before removal.
632 $window = $this->getInventoryWindow($inventory);
633 if($window !== null){
634 $this->syncContents($window);
635 }
636 }
637
638 private function syncContents(InventoryWindow $window) : void{
639 $entry = $this->getEntryByWindow($window);
640 if($entry === null){
641 //this can happen when an inventory changed during InventoryCloseEvent, or when a temporary inventory
642 //is cleared before removal.
643 return;
644 }
645 if($entry->complexSlotMap !== null){
646 $windowId = ContainerIds::UI;
647 }else{
648 $windowId = $this->getWindowId($window);
649 }
650 if($windowId !== null){
651 $entry->predictions = [];
652 $entry->pendingSyncs = [];
653 $contents = [];
654 $typeConverter = $this->session->getTypeConverter();
655 foreach($window->getInventory()->getContents(true) as $slot => $item){
656 $itemStack = $typeConverter->coreItemStackToNet($item);
657 $info = $this->trackItemStack($entry, $slot, $itemStack, null);
658 $contents[] = new ItemStackWrapper($info->getStackId(), $itemStack);
659 }
660 if($entry->complexSlotMap !== null){
661 foreach($contents as $slotId => $info){
662 $packetSlot = $entry->complexSlotMap->mapCoreToNet($slotId) ?? null;
663 if($packetSlot === null){
664 continue;
665 }
666 $this->sendInventorySlotPackets($windowId, $packetSlot, $info);
667 }
668 }else{
669 $this->sendInventoryContentPackets($windowId, $contents);
670 }
671 }
672 }
673
674 public function syncAll() : void{
675 foreach($this->entries as $entry){
676 $this->syncContents($entry->window);
677 }
678 }
679
680 public function requestSyncAll() : void{
681 $this->fullSyncRequested = true;
682 }
683
684 public function syncMismatchedPredictedSlotChanges() : void{
685 $typeConverter = $this->session->getTypeConverter();
686 foreach($this->entries as $entry){
687 $inventory = $entry->window->getInventory();
688 foreach($entry->predictions as $slot => $expectedItem){
689 if(!$inventory->slotExists($slot) || $entry->itemStackInfos[$slot] === null){
690 continue; //TODO: size desync ???
691 }
692
693 //any prediction that still exists at this point is a slot that was predicted to change but didn't
694 $this->session->getLogger()->debug("Detected prediction mismatch in inventory " . get_class($inventory) . "#" . spl_object_id($inventory) . " slot $slot");
695 $entry->pendingSyncs[$slot] = $typeConverter->coreItemStackToNet($inventory->getItem($slot));
696 }
697
698 $entry->predictions = [];
699 }
700 }
701
702 public function flushPendingUpdates() : void{
703 if($this->fullSyncRequested){
704 $this->fullSyncRequested = false;
705 $this->session->getLogger()->debug("Full inventory sync requested, sending contents of " . count($this->entries) . " inventories");
706 $this->syncAll();
707 }else{
708 foreach($this->entries as $entry){
709 if(count($entry->pendingSyncs) === 0){
710 continue;
711 }
712 $inventory = $entry->window;
713 $this->session->getLogger()->debug("Syncing slots " . implode(", ", array_keys($entry->pendingSyncs)) . " in inventory " . get_class($inventory) . "#" . spl_object_id($inventory));
714 foreach($entry->pendingSyncs as $slot => $itemStack){
715 $this->syncSlot($inventory, $slot, $itemStack);
716 }
717 $entry->pendingSyncs = [];
718 }
719 }
720 }
721
722 public function syncData(Inventory $inventory, int $propertyId, int $value) : void{
723 //TODO: the handling of this data has always kinda sucked. Probably ought to route it through InventoryWindow
724 //somehow, but I'm not sure exactly how that should look.
725 $window = $this->getInventoryWindow($inventory);
726 if($window === null){
727 return;
728 }
729 $windowId = $this->getWindowId($window);
730 if($windowId !== null){
731 $this->session->sendDataPacket(ContainerSetDataPacket::create($windowId, $propertyId, $value));
732 }
733 }
734
735 public function onClientSelectHotbarSlot(int $slot) : void{
736 $this->clientSelectedHotbarSlot = $slot;
737 }
738
739 public function syncSelectedHotbarSlot() : void{
740 $playerInventory = $this->player->getInventory();
741 $selected = $this->player->getHotbar()->getSelectedIndex();
742 if($selected !== $this->clientSelectedHotbarSlot){
743 $inventoryEntry = $this->getEntry($playerInventory) ?? throw new AssumptionFailedError("Player inventory should always be tracked");
744 $itemStackInfo = $inventoryEntry->itemStackInfos[$selected] ?? null;
745 if($itemStackInfo === null){
746 throw new AssumptionFailedError("Untracked player inventory slot $selected");
747 }
748
749 $this->session->sendDataPacket(MobEquipmentPacket::create(
750 $this->player->getId(),
751 new ItemStackWrapper($itemStackInfo->getStackId(), $this->session->getTypeConverter()->coreItemStackToNet($playerInventory->getItem($selected))),
752 $selected,
753 $selected,
754 ContainerIds::INVENTORY
755 ));
756 $this->clientSelectedHotbarSlot = $selected;
757 }
758 }
759
760 public function syncCreative() : void{
761 $this->session->sendDataPacket(CreativeInventoryCache::getInstance()->buildPacket($this->player->getCreativeInventory(), $this->session));
762 }
763
768 public function syncEnchantingTableOptions(array $options) : void{
769 $protocolOptions = [];
770
771 foreach($options as $index => $option){
772 $optionId = $this->nextEnchantingTableOptionId++;
773 $this->enchantingTableOptions[$optionId] = $index;
774
775 $protocolEnchantments = array_map(
776 fn(EnchantmentInstance $e) => new Enchant(EnchantmentIdMap::getInstance()->toId($e->getType()), $e->getLevel()),
777 $option->getEnchantments()
778 );
779 // We don't pay attention to the $slotFlags, $heldActivatedEnchantments and $selfActivatedEnchantments
780 // as everything works fine without them (perhaps these values are used somehow in the BDS).
781 $protocolOptions[] = new ProtocolEnchantOption(
782 $option->getRequiredXpLevel(),
783 0, $protocolEnchantments,
784 [],
785 [],
786 $option->getDisplayName(),
787 $optionId
788 );
789 }
790
791 $this->session->sendDataPacket(PlayerEnchantOptionsPacket::create($protocolOptions));
792 }
793
794 public function getEnchantingTableOptionIndex(int $recipeId) : ?int{
795 return $this->enchantingTableOptions[$recipeId] ?? null;
796 }
797
798 private function newItemStackId() : int{
799 return $this->nextItemStackId++;
800 }
801
802 public function getItemStackInfo(InventoryWindow $window, int $slot) : ?ItemStackInfo{
803 return $this->getEntryByWindow($window)?->itemStackInfos[$slot] ?? null;
804 }
805
806 private function trackItemStack(InventoryManagerEntry $entry, int $slotId, ItemStack $itemStack, ?int $itemStackRequestId) : ItemStackInfo{
807 //TODO: ItemStack->isNull() would be nice to have here
808 $info = new ItemStackInfo($itemStackRequestId, $itemStack->getId() === 0 ? 0 : $this->newItemStackId());
809 return $entry->itemStackInfos[$slotId] = $info;
810 }
811}
static createContainerOpen(int $id, InventoryWindow $window)
locateWindowAndSlot(int $windowId, int $netSlotId)
onContentChange(Inventory $inventory, array $oldContents)
addRawPredictedSlotChanges(array $networkInventoryActions)