DDylan K. Taylorstfu
e569cc32创建于 2025年9月1日历史提交
<?php

/*
 *
 *  ____            _        _   __  __ _                  __  __ ____
 * |  _ \ ___   ___| | _____| |_|  \/  (_)_ __   ___      |  \/  |  _ \
 * | |_) / _ \ / __| |/ / _ \ __| |\/| | | '_ \ / _ \_____| |\/| | |_) |
 * |  __/ (_) | (__|   <  __/ |_| |  | | | | | |  __/_____| |  | |  __/
 * |_|   \___/ \___|_|\_\___|\__|_|  |_|_|_| |_|\___|     |_|  |_|_|
 *
 * This program is free software: you can redistribute it and/or modify
 * it under the terms of the GNU Lesser General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * @author PocketMine Team
 * @link http://www.pocketmine.net/
 *
 *
 */

declare(strict_types=1);

namespace pocketmine\inventory\transaction;

use pocketmine\event\inventory\InventoryTransactionEvent;
use pocketmine\inventory\Inventory;
use pocketmine\inventory\transaction\action\InventoryAction;
use pocketmine\inventory\transaction\action\SlotChangeAction;
use pocketmine\item\Item;
use pocketmine\player\Player;
use pocketmine\utils\Utils;
use function array_values;
use function assert;
use function count;
use function get_class;
use function min;
use function spl_object_hash;
use function spl_object_id;

/**
 * This is the basic type for an inventory transaction. This is used for moving items between inventories, dropping
 * items and more. It allows transactions with multiple inputs and outputs.
 *
 * Validation **does not** depend on ordering. This means that the actions can appear in any order and still be valid.
 * The only validity requirement for this transaction type is that the balance of items must add up to zero. This means:
 * - No new outputs without matching input amounts
 * - No inputs without matching output amounts
 * - No userdata changes (item state, NBT, etc)
 *
 * A transaction is composed of "actions", which are pairs of inputs and outputs which target a specific itemstack in
 * a specific location. There are multiple types of inventory actions which might be involved in a transaction.
 *
 * @see InventoryAction
 */
class InventoryTransaction{
	protected bool $hasExecuted = false;

	/**
	 * @var Inventory[]
	 * @phpstan-var array<int, Inventory>
	 */
	protected array $inventories = [];

	/**
	 * @var InventoryAction[]
	 * @phpstan-var array<int, InventoryAction>
	 */
	protected array $actions = [];

	/**
	 * @param InventoryAction[] $actions
	 */
	public function __construct(
		protected Player $source,
		array $actions = []
	){
		foreach($actions as $action){
			$this->addAction($action);
		}
	}

	public function getSource() : Player{
		return $this->source;
	}

	/**
	 * @return Inventory[]
	 * @phpstan-return array<int, Inventory>
	 */
	public function getInventories() : array{
		return $this->inventories;
	}

	/**
	 * Returns a set of actions involved in this transaction.
	 *
	 * Note: This system is designed to care only about item balances. While you can usually assume that the actions
	 * are provided in the correct order, it will still successfully complete transactions whose actions are provided in
	 * the "wrong" order, as long as the transaction balances.
	 * For example, you may see that an action setting a slot to a particular item may appear before the action that
	 * removes that item from its original slot. While unintuitive, this is still valid.
	 *
	 * @return InventoryAction[]
	 * @phpstan-return array<int, InventoryAction>
	 */
	public function getActions() : array{
		return $this->actions;
	}

	public function addAction(InventoryAction $action) : void{
		if(!isset($this->actions[$hash = spl_object_id($action)])){
			$this->actions[$hash] = $action;
			$action->onAddToTransaction($this);
			if($action instanceof SlotChangeAction && !isset($this->inventories[$inventoryId = spl_object_id($action->getInventory())])){
				$this->inventories[$inventoryId] = $action->getInventory();
			}
		}else{
			throw new \InvalidArgumentException("Tried to add the same action to a transaction twice");
		}
	}

	/**
	 * @param Item[] $needItems
	 * @param Item[] $haveItems
	 * @phpstan-param-out list<Item> $needItems
	 * @phpstan-param-out list<Item> $haveItems
	 *
	 * @throws TransactionValidationException
	 */
	protected function matchItems(array &$needItems, array &$haveItems) : void{
		$needItems = [];
		$haveItems = [];
		foreach($this->actions as $key => $action){
			$targetItem = $action->getTargetItem();
			if(!$targetItem->isNull()){
				$needItems[] = $targetItem;
			}

			try{
				$action->validate($this->source);
			}catch(TransactionValidationException $e){
				throw new TransactionValidationException(get_class($action) . "#" . spl_object_id($action) . ": " . $e->getMessage(), 0, $e);
			}

			$sourceItem = $action->getSourceItem();
			if(!$sourceItem->isNull()){
				$haveItems[] = $sourceItem;
			}
		}

		foreach($needItems as $i => $needItem){
			foreach($haveItems as $j => $haveItem){
				if($needItem->canStackWith($haveItem)){
					$amount = min($needItem->getCount(), $haveItem->getCount());
					$needItem->setCount($needItem->getCount() - $amount);
					$haveItem->setCount($haveItem->getCount() - $amount);
					if($haveItem->getCount() === 0){
						unset($haveItems[$j]);
					}
					if($needItem->getCount() === 0){
						unset($needItems[$i]);
						break;
					}
				}
			}
		}
		$needItems = array_values($needItems);
		$haveItems = array_values($haveItems);
	}

	/**
	 * Iterates over SlotChangeActions in this transaction and compacts any which refer to the same slot in the same
	 * inventory so they can be correctly handled.
	 *
	 * Under normal circumstances, the same slot would never be changed more than once in a single transaction. However,
	 * due to the way things like the crafting grid are "implemented" in MCPE 1.2 (a.k.a. hacked-in), we may get
	 * multiple slot changes referring to the same slot in a single transaction. These multiples are not even guaranteed
	 * to be in the correct order (slot splitting in the crafting grid for example, causes the actions to be sent in the
	 * wrong order), so this method also tries to chain them into order.
	 */
	protected function squashDuplicateSlotChanges() : void{
		$slotChanges = [];
		$inventories = [];
		$slots = [];

		foreach($this->actions as $key => $action){
			if($action instanceof SlotChangeAction){
				$slotChanges[$h = (spl_object_hash($action->getInventory()) . "@" . $action->getSlot())][] = $action;
				$inventories[$h] = $action->getInventory();
				$slots[$h] = $action->getSlot();
			}
		}

		foreach(Utils::stringifyKeys($slotChanges) as $hash => $list){
			if(count($list) === 1){ //No need to compact slot changes if there is only one on this slot
				continue;
			}

			$inventory = $inventories[$hash];
			$slot = $slots[$hash];
			if(!$inventory->slotExists($slot)){ //this can get hit for crafting tables because the validation happens after this compaction
				throw new TransactionValidationException("Slot $slot does not exist in inventory " . get_class($inventory));
			}
			$sourceItem = $inventory->getItem($slot);

			$targetItem = $this->findResultItem($sourceItem, $list);
			if($targetItem === null){
				throw new TransactionValidationException("Failed to compact " . count($list) . " duplicate actions");
			}

			foreach($list as $action){
				unset($this->actions[spl_object_id($action)]);
			}

			if(!$targetItem->equalsExact($sourceItem)){
				//sometimes we get actions on the crafting grid whose source and target items are the same, so dump them
				$this->addAction(new SlotChangeAction($inventory, $slot, $sourceItem, $targetItem));
			}
		}
	}

	/**
	 * @param SlotChangeAction[] $possibleActions
	 * @phpstan-param array<int, SlotChangeAction> $possibleActions
	 */
	protected function findResultItem(Item $needOrigin, array $possibleActions) : ?Item{
		assert(count($possibleActions) > 0);

		$candidate = null;
		$newList = $possibleActions;
		foreach($possibleActions as $i => $action){
			if($action->getSourceItem()->equalsExact($needOrigin)){
				if($candidate !== null){
					/*
					 * we found multiple possible actions that match the origin action
					 * this means that there are multiple ways that this chain could play out
					 * if we cared so much about this, we could build all the possible chains in parallel and see which
					 * variation managed to complete the chain, but this has an extremely high complexity which is not
					 * worth the trouble for this scenario (we don't usually expect to see chains longer than a couple
					 * of actions in here anyway), and might still result in multiple possible results.
					 */
					return null;
				}
				$candidate = $action;
				unset($newList[$i]);
			}
		}
		if($candidate === null){
			//chaining is not possible with this origin, none of the actions are valid
			return null;
		}

		if(count($newList) === 0){
			return $candidate->getTargetItem();
		}
		return $this->findResultItem($candidate->getTargetItem(), $newList);
	}

	/**
	 * Verifies that the transaction can execute.
	 *
	 * @throws TransactionValidationException
	 */
	public function validate() : void{
		$this->squashDuplicateSlotChanges();

		$haveItems = [];
		$needItems = [];
		$this->matchItems($needItems, $haveItems);
		if(count($this->actions) === 0){
			throw new TransactionValidationException("Inventory transaction must have at least one action to be executable");
		}

		if(count($haveItems) > 0){
			throw new TransactionValidationException("Transaction does not balance (tried to destroy some items)");
		}
		if(count($needItems) > 0){
			throw new TransactionValidationException("Transaction does not balance (tried to create some items)");
		}
	}

	protected function callExecuteEvent() : bool{
		$ev = new InventoryTransactionEvent($this);
		$ev->call();
		return !$ev->isCancelled();
	}

	/**
	 * Executes the group of actions, returning whether the transaction executed successfully or not.
	 *
	 * @throws TransactionException
	 */
	public function execute() : void{
		if($this->hasExecuted()){
			throw new TransactionValidationException("Transaction has already been executed");
		}

		$this->validate();

		if(!$this->callExecuteEvent()){
			throw new TransactionCancelledException("Transaction event cancelled");
		}

		foreach($this->actions as $action){
			if(!$action->onPreExecute($this->source)){
				throw new TransactionCancelledException("One of the actions in this transaction was cancelled");
			}
		}

		foreach($this->actions as $action){
			$action->execute($this->source);
		}

		$this->hasExecuted = true;
	}

	public function hasExecuted() : bool{
		return $this->hasExecuted;
	}
}