PocketMine-MP 5.33.2 git-919492bdcad8510eb6606439eb77e1c604f1d1ea
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;
71use function array_fill_keys;
72use function array_keys;
73use function array_map;
74use function array_search;
75use function count;
76use function get_class;
77use function implode;
78use function is_int;
79use function max;
80use function spl_object_id;
81
90 private array $entries = [];
91
96 private array $networkIdToWindowMap = [];
101 private array $complexSlotToWindowMap = [];
102
103 private int $lastWindowNetworkId = ContainerIds::FIRST;
104 private int $currentWindowType = WindowTypes::CONTAINER;
105
106 private int $clientSelectedHotbarSlot = -1;
107
109 private ObjectSet $containerOpenCallbacks;
110
111 private ?int $pendingCloseWindowId = null;
113 private ?\Closure $pendingOpenWindowCallback = null;
114
115 private int $nextItemStackId = 1;
116 private ?int $currentItemStackRequestId = null;
117
118 private bool $fullSyncRequested = false;
119
121 private array $enchantingTableOptions = [];
122 //TODO: this should be based on the total number of crafting recipes - if there are ever 100k recipes, this will
123 //conflict with regular recipes
124 private int $nextEnchantingTableOptionId = 100000;
125
126 public function __construct(
127 private Player $player,
128 private NetworkSession $session
129 ){
130 $this->containerOpenCallbacks = new ObjectSet();
131 $this->containerOpenCallbacks->add(self::createContainerOpen(...));
132
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),
140 default => throw new AssumptionFailedError("Unknown permanent window type " . $window->getType())
141 };
142 }
143
144 $this->player->getHotbar()->getSelectedIndexChangeListeners()->add($this->syncSelectedHotbarSlot(...));
145 }
146
147 private function associateIdWithInventory(int $id, InventoryWindow $window) : void{
148 $this->networkIdToWindowMap[$id] = $window;
149 }
150
151 private function getNewWindowId() : int{
152 $this->lastWindowNetworkId = max(ContainerIds::FIRST, ($this->lastWindowNetworkId + 1) % ContainerIds::LAST);
153 return $this->lastWindowNetworkId;
154 }
155
156 private function getEntry(Inventory $inventory) : ?InventoryManagerEntry{
157 return $this->entries[spl_object_id($inventory)] ?? null;
158 }
159
160 private function getEntryByWindow(InventoryWindow $window) : ?InventoryManagerEntry{
161 return $this->getEntry($window->getInventory());
162 }
163
164 public function getInventoryWindow(Inventory $inventory) : ?InventoryWindow{
165 return $this->getEntry($inventory)?->window;
166 }
167
168 private function add(int $id, InventoryWindow $window) : void{
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?)");
172 }
173 $this->entries[$k] = new InventoryManagerEntry($window);
174 $window->getInventory()->getListeners()->add($this);
175 $this->associateIdWithInventory($id, $window);
176 }
177
178 private function addDynamic(InventoryWindow $inventory) : int{
179 $id = $this->getNewWindowId();
180 $this->add($id, $inventory);
181 return $id;
182 }
183
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");
192 }
193 $complexSlotMap = new ComplexWindowMapEntry($window, is_int($slotMap) ? [$slotMap => 0] : $slotMap);
194 $this->entries[$k] = new InventoryManagerEntry(
195 $window,
196 $complexSlotMap
197 );
198 $window->getInventory()->getListeners()->add($this);
199 foreach($complexSlotMap->getSlotMap() as $netSlot => $coreSlot){
200 $this->complexSlotToWindowMap[$netSlot] = $complexSlotMap;
201 }
202 }
203
208 private function addComplexDynamic(array|int $slotMap, InventoryWindow $inventory) : int{
209 $this->addComplex($slotMap, $inventory);
210 $id = $this->getNewWindowId();
211 $this->associateIdWithInventory($id, $inventory);
212 return $id;
213 }
214
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){
220 $inventory->getListeners()->remove($this);
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]);
225 }
226 }
227 }
228 }
229
230 public function getWindowId(InventoryWindow $window) : ?int{
231 return ($id = array_search($window, $this->networkIdToWindowMap, true)) !== false ? $id : null;
232 }
233
234 public function getCurrentWindowId() : int{
235 return $this->lastWindowNetworkId;
236 }
237
241 public function locateWindowAndSlot(int $windowId, int $netSlotId) : ?array{
242 if($windowId === ContainerIds::UI){
243 $entry = $this->complexSlotToWindowMap[$netSlotId] ?? null;
244 if($entry === null){
245 return null;
246 }
247 $window = $entry->getWindow();
248 $coreSlotId = $entry->mapNetToCore($netSlotId);
249 return $coreSlotId !== null && $window->getInventory()->slotExists($coreSlotId) ? [$window, $coreSlotId] : null;
250 }
251 $window = $this->networkIdToWindowMap[$windowId] ?? null;
252 if($window !== null && $window->getInventory()->slotExists($netSlotId)){
253 return [$window, $netSlotId];
254 }
255 return null;
256 }
257
258 private function addPredictedSlotChangeInternal(InventoryWindow $window, int $slot, ItemStack $item) : void{
259 //TODO: does this need a null check?
260 $entry = $this->getEntryByWindow($window) ?? throw new AssumptionFailedError("Assume this should never be null");
261 $entry->predictions[$slot] = $item;
262 }
263
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);
268 }
269
270 public function addTransactionPredictedSlotChanges(InventoryTransaction $tx) : void{
271 foreach($tx->getActions() as $action){
272 if($action instanceof SlotChangeAction){
273 //TODO: ItemStackRequestExecutor can probably build these predictions with much lower overhead
274 $this->addPredictedSlotChange(
275 $action->getInventoryWindow(),
276 $action->getSlot(),
277 $action->getTargetItem()
278 );
279 }
280 }
281 }
282
287 public function addRawPredictedSlotChanges(array $networkInventoryActions) : void{
288 foreach($networkInventoryActions as $action){
289 if($action->sourceType !== NetworkInventoryAction::SOURCE_CONTAINER){
290 continue;
291 }
292
293 //legacy transactions should not modify or predict anything other than these inventories, since these are
294 //the only ones accessible when not in-game (ItemStackRequest is used for everything else)
295 if(match($action->windowId){
296 ContainerIds::INVENTORY, ContainerIds::OFFHAND, ContainerIds::ARMOR => false,
297 default => true
298 }){
299 throw new PacketHandlingException("Legacy transactions cannot predict changes to inventory with ID " . $action->windowId);
300 }
301 $info = $this->locateWindowAndSlot($action->windowId, $action->inventorySlot);
302 if($info === null){
303 continue;
304 }
305
306 [$window, $slot] = $info;
307 $this->addPredictedSlotChangeInternal($window, $slot, $action->newItem->getItemStack());
308 }
309 }
310
311 public function setCurrentItemStackRequestId(?int $id) : void{
312 $this->currentItemStackRequestId = $id;
313 }
314
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;
333 }else{
334 $func();
335 }
336 }
337
342 private function createComplexSlotMapping(InventoryWindow $inventory) : ?array{
343 //TODO: make this dynamic so plugins can add mappings for stuff not implemented by PM
344 return match(true){
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,
352 default => null,
353 };
354 }
355
356 public function onCurrentWindowChange(InventoryWindow $window) : void{
357 $this->onCurrentWindowRemove();
358
359 $this->openWindowDeferred(function() use ($window) : void{
360 if(($slotMap = $this->createComplexSlotMapping($window)) !== null){
361 $windowId = $this->addComplexDynamic($slotMap, $window);
362 }else{
363 $windowId = $this->addDynamic($window);
364 }
365
366 foreach($this->containerOpenCallbacks as $callback){
367 $pks = $callback($windowId, $window);
368 if($pks !== null){
369 $windowType = null;
370 foreach($pks as $pk){
371 if($pk instanceof ContainerOpenPacket){
372 //workaround useless bullshit in 1.21 - ContainerClose requires a type now for some reason
373 $windowType = $pk->windowType;
374 }
375 $this->session->sendDataPacket($pk);
376 }
377 $this->currentWindowType = $windowType ?? WindowTypes::CONTAINER;
378 $this->syncContents($window);
379 return;
380 }
381 }
382 throw new \LogicException("Unsupported inventory type");
383 });
384 }
385
387 public function getContainerOpenCallbacks() : ObjectSet{ return $this->containerOpenCallbacks; }
388
393 protected static function createContainerOpen(int $id, InventoryWindow $window) : ?array{
394 //TODO: we should be using some kind of tagging system to identify the types. Instanceof is flaky especially
395 //if the class isn't final, not to mention being inflexible.
396 if($window instanceof BlockInventoryWindow){
397 $blockPosition = BlockPosition::fromVector3($window->getHolder());
398 $windowType = match(true){
399 $window instanceof LoomInventoryWindow => WindowTypes::LOOM,
400 $window instanceof FurnaceInventoryWindow => match($window->getFurnaceType()){
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")
405 },
406 $window instanceof EnchantingTableInventoryWindow => WindowTypes::ENCHANTMENT,
407 $window instanceof BrewingStandInventoryWindow => WindowTypes::BREWING_STAND,
408 $window instanceof AnvilInventoryWindow => WindowTypes::ANVIL,
409 $window instanceof HopperInventoryWindow => WindowTypes::HOPPER,
410 $window instanceof CraftingTableInventoryWindow => WindowTypes::WORKBENCH,
411 $window instanceof StonecutterInventoryWindow => WindowTypes::STONECUTTER,
412 $window instanceof CartographyTableInventoryWindow => WindowTypes::CARTOGRAPHY,
413 $window instanceof SmithingTableInventoryWindow => WindowTypes::SMITHING_TABLE,
414 default => WindowTypes::CONTAINER
415 };
416 return [ContainerOpenPacket::blockInv($id, $windowType, $blockPosition)];
417 }
418 return null;
419 }
420
421 public function onClientOpenMainInventory() : void{
422 $this->onCurrentWindowRemove();
423
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;
429
430 $this->session->sendDataPacket(ContainerOpenPacket::entityInv(
431 $windowId,
432 $this->currentWindowType,
433 $this->player->getId()
434 ));
435 });
436 }
437
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");
444 }
445 $this->pendingCloseWindowId = $this->lastWindowNetworkId;
446 $this->enchantingTableOptions = [];
447 }
448 }
449
450 public function onClientRemoveWindow(int $id) : void{
451 if($id === $this->lastWindowNetworkId){
452 if(isset($this->networkIdToWindowMap[$id]) && $id !== $this->pendingCloseWindowId){
453 $this->remove($id);
454 $this->player->removeCurrentWindow();
455 }
456 }else{
457 $this->session->getLogger()->debug("Attempted to close inventory with network ID $id, but current is $this->lastWindowNetworkId");
458 }
459
460 //Always send this, even if no window matches. If we told the client to close a window, it will behave as if it
461 //initiated the close and expect an ack.
462 $this->session->sendDataPacket(ContainerClosePacket::create($id, $this->currentWindowType, false));
463
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;
470 }
471 }
472 }
473
481 private function itemStackExtraDataEqual(ItemStack $left, ItemStack $right) : bool{
482 if($left->getRawExtraData() === $right->getRawExtraData()){
483 return true;
484 }
485
486 $typeConverter = $this->session->getTypeConverter();
487 $leftExtraData = $typeConverter->deserializeItemStackExtraData($left->getRawExtraData(), $left->getId());
488 $rightExtraData = $typeConverter->deserializeItemStackExtraData($right->getRawExtraData(), $right->getId());
489
490 $leftNbt = $leftExtraData->getNbt();
491 $rightNbt = $rightExtraData->getNbt();
492 return
493 $leftExtraData->getCanPlaceOn() === $rightExtraData->getCanPlaceOn() &&
494 $leftExtraData->getCanDestroy() === $rightExtraData->getCanDestroy() && (
495 $leftNbt === $rightNbt || //this covers null === null and fast object identity
496 ($leftNbt !== null && $rightNbt !== null && $leftNbt->equals($rightNbt))
497 );
498 }
499
500 private function itemStacksEqual(ItemStack $left, ItemStack $right) : bool{
501 return
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);
507 }
508
509 public function onSlotChange(Inventory $inventory, int $slot, Item $oldItem) : void{
510 $window = $this->getInventoryWindow($inventory);
511 if($window === null){
512 //this can happen when an inventory changed during InventoryCloseEvent, or when a temporary inventory
513 //is cleared before removal.
514 return;
515 }
516 $this->requestSyncSlot($window, $slot);
517 }
518
519 public function requestSyncSlot(InventoryWindow $window, int $slot) : void{
520 $inventoryEntry = $this->getEntryByWindow($window);
521 if($inventoryEntry === 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
527 $currentItem = $this->session->getTypeConverter()->coreItemStackToNet($window->getInventory()->getItem($slot));
528 $clientSideItem = $inventoryEntry->predictions[$slot] ?? null;
529 if($clientSideItem === null || !$this->itemStacksEqual($currentItem, $clientSideItem)){
530 //no prediction or incorrect - do not associate this with the currently active itemstack request
531 $this->trackItemStack($inventoryEntry, $slot, $currentItem, null);
532 $inventoryEntry->pendingSyncs[$slot] = $currentItem;
533 }else{
534 //correctly predicted - associate the change with the currently active itemstack request
535 $this->trackItemStack($inventoryEntry, $slot, $currentItem, $this->currentItemStackRequestId);
536 }
537
538 unset($inventoryEntry->predictions[$slot]);
539 }
540
541 private function sendInventorySlotPackets(int $windowId, int $netSlot, ItemStackWrapper $itemStackWrapper) : void{
542 /*
543 * TODO: HACK!
544 * As of 1.20.12, the client ignores change of itemstackID in some cases when the old item == the new item.
545 * Notably, this happens with armor, offhand and enchanting tables, but not with main inventory.
546 * While we could track the items previously sent to the client, that's a waste of memory and would
547 * cost performance. Instead, clear the slot(s) first, then send the new item(s).
548 * The network cost of doing this is fortunately minimal, as an air itemstack is only 1 byte.
549 */
550 if($itemStackWrapper->getStackId() !== 0){
551 $this->session->sendDataPacket(InventorySlotPacket::create(
552 $windowId,
553 $netSlot,
554 new FullContainerName($this->lastWindowNetworkId),
555 new ItemStackWrapper(0, ItemStack::null()),
556 new ItemStackWrapper(0, ItemStack::null())
557 ));
558 }
559 //now send the real contents
560 $this->session->sendDataPacket(InventorySlotPacket::create(
561 $windowId,
562 $netSlot,
563 new FullContainerName($this->lastWindowNetworkId),
564 new ItemStackWrapper(0, ItemStack::null()),
565 $itemStackWrapper
566 ));
567 }
568
572 private function sendInventoryContentPackets(int $windowId, array $itemStackWrappers) : void{
573 /*
574 * TODO: HACK!
575 * As of 1.20.12, the client ignores change of itemstackID in some cases when the old item == the new item.
576 * Notably, this happens with armor, offhand and enchanting tables, but not with main inventory.
577 * While we could track the items previously sent to the client, that's a waste of memory and would
578 * cost performance. Instead, clear the slot(s) first, then send the new item(s).
579 * The network cost of doing this is fortunately minimal, as an air itemstack is only 1 byte.
580 */
581 $this->session->sendDataPacket(InventoryContentPacket::create(
582 $windowId,
583 array_fill_keys(array_keys($itemStackWrappers), new ItemStackWrapper(0, ItemStack::null())),
584 new FullContainerName($this->lastWindowNetworkId),
585 new ItemStackWrapper(0, ItemStack::null())
586 ));
587 //now send the real contents
588 $this->session->sendDataPacket(InventoryContentPacket::create($windowId, $itemStackWrappers, new FullContainerName($this->lastWindowNetworkId), new ItemStackWrapper(0, ItemStack::null())));
589 }
590
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");
596 }
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");
600 }else{
601 $windowId = $this->getWindowId($window) ?? throw new AssumptionFailedError("We already have an ItemStackInfo, so this should not be null");
602 $netSlot = $slot;
603 }
604
605 $itemStackWrapper = new ItemStackWrapper($itemStackInfo->getStackId(), $itemStack);
606 if($windowId === ContainerIds::OFFHAND){
607 //TODO: HACK!
608 //The client may sometimes ignore the InventorySlotPacket for the offhand slot.
609 //This can cause a lot of problems (totems, arrows, and more...).
610 //The workaround is to send an InventoryContentPacket instead
611 //BDS (Bedrock Dedicated Server) also seems to work this way.
612 $this->sendInventoryContentPackets($windowId, [$itemStackWrapper]);
613 }else{
614 $this->sendInventorySlotPackets($windowId, $netSlot, $itemStackWrapper);
615 }
616 unset($entry->predictions[$slot], $entry->pendingSyncs[$slot]);
617 }
618
619 public function onContentChange(Inventory $inventory, array $oldContents) : void{
620 //this can be null when an inventory changed during InventoryCloseEvent, or when a temporary inventory
621 //is cleared before removal.
622 $window = $this->getInventoryWindow($inventory);
623 if($window !== null){
624 $this->syncContents($window);
625 }
626 }
627
628 private function syncContents(InventoryWindow $window) : void{
629 $entry = $this->getEntryByWindow($window);
630 if($entry === null){
631 //this can happen when an inventory changed during InventoryCloseEvent, or when a temporary inventory
632 //is cleared before removal.
633 return;
634 }
635 if($entry->complexSlotMap !== null){
636 $windowId = ContainerIds::UI;
637 }else{
638 $windowId = $this->getWindowId($window);
639 }
640 if($windowId !== null){
641 $entry->predictions = [];
642 $entry->pendingSyncs = [];
643 $contents = [];
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);
649 }
650 if($entry->complexSlotMap !== null){
651 foreach($contents as $slotId => $info){
652 $packetSlot = $entry->complexSlotMap->mapCoreToNet($slotId) ?? null;
653 if($packetSlot === null){
654 continue;
655 }
656 $this->sendInventorySlotPackets($windowId, $packetSlot, $info);
657 }
658 }else{
659 $this->sendInventoryContentPackets($windowId, $contents);
660 }
661 }
662 }
663
664 public function syncAll() : void{
665 foreach($this->entries as $entry){
666 $this->syncContents($entry->window);
667 }
668 }
669
670 public function requestSyncAll() : void{
671 $this->fullSyncRequested = true;
672 }
673
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){
680 continue; //TODO: size desync ???
681 }
682
683 //any prediction that still exists at this point is a slot that was predicted to change but didn't
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));
686 }
687
688 $entry->predictions = [];
689 }
690 }
691
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");
696 $this->syncAll();
697 }else{
698 foreach($this->entries as $entry){
699 if(count($entry->pendingSyncs) === 0){
700 continue;
701 }
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);
706 }
707 $entry->pendingSyncs = [];
708 }
709 }
710 }
711
712 public function syncData(Inventory $inventory, int $propertyId, int $value) : void{
713 //TODO: the handling of this data has always kinda sucked. Probably ought to route it through InventoryWindow
714 //somehow, but I'm not sure exactly how that should look.
715 $window = $this->getInventoryWindow($inventory);
716 if($window === null){
717 return;
718 }
719 $windowId = $this->getWindowId($window);
720 if($windowId !== null){
721 $this->session->sendDataPacket(ContainerSetDataPacket::create($windowId, $propertyId, $value));
722 }
723 }
724
725 public function onClientSelectHotbarSlot(int $slot) : void{
726 $this->clientSelectedHotbarSlot = $slot;
727 }
728
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");
737 }
738
739 $this->session->sendDataPacket(MobEquipmentPacket::create(
740 $this->player->getId(),
741 new ItemStackWrapper($itemStackInfo->getStackId(), $this->session->getTypeConverter()->coreItemStackToNet($playerInventory->getItem($selected))),
742 $selected,
743 $selected,
744 ContainerIds::INVENTORY
745 ));
746 $this->clientSelectedHotbarSlot = $selected;
747 }
748 }
749
750 public function syncCreative() : void{
751 $this->session->sendDataPacket(CreativeInventoryCache::getInstance()->buildPacket($this->player->getCreativeInventory(), $this->session));
752 }
753
758 public function syncEnchantingTableOptions(array $options) : void{
759 $protocolOptions = [];
760
761 foreach($options as $index => $option){
762 $optionId = $this->nextEnchantingTableOptionId++;
763 $this->enchantingTableOptions[$optionId] = $index;
764
765 $protocolEnchantments = array_map(
766 fn(EnchantmentInstance $e) => new Enchant(EnchantmentIdMap::getInstance()->toId($e->getType()), $e->getLevel()),
767 $option->getEnchantments()
768 );
769 // We don't pay attention to the $slotFlags, $heldActivatedEnchantments and $selfActivatedEnchantments
770 // as everything works fine without them (perhaps these values are used somehow in the BDS).
771 $protocolOptions[] = new ProtocolEnchantOption(
772 $option->getRequiredXpLevel(),
773 0, $protocolEnchantments,
774 [],
775 [],
776 $option->getDisplayName(),
777 $optionId
778 );
779 }
780
781 $this->session->sendDataPacket(PlayerEnchantOptionsPacket::create($protocolOptions));
782 }
783
784 public function getEnchantingTableOptionIndex(int $recipeId) : ?int{
785 return $this->enchantingTableOptions[$recipeId] ?? null;
786 }
787
788 private function newItemStackId() : int{
789 return $this->nextItemStackId++;
790 }
791
792 public function getItemStackInfo(InventoryWindow $window, int $slot) : ?ItemStackInfo{
793 return $this->getEntryByWindow($window)?->itemStackInfos[$slot] ?? null;
794 }
795
796 private function trackItemStack(InventoryManagerEntry $entry, int $slotId, ItemStack $itemStack, ?int $itemStackRequestId) : ItemStackInfo{
797 //TODO: ItemStack->isNull() would be nice to have here
798 $info = new ItemStackInfo($itemStackRequestId, $itemStack->getId() === 0 ? 0 : $this->newItemStackId());
799 return $entry->itemStackInfos[$slotId] = $info;
800 }
801}
static createContainerOpen(int $id, InventoryWindow $window)
locateWindowAndSlot(int $windowId, int $netSlotId)
onContentChange(Inventory $inventory, array $oldContents)
addRawPredictedSlotChanges(array $networkInventoryActions)