From c70397d2d2d357500d960c4c670fd12b5680125d Mon Sep 17 00:00:00 2001
From: ShockedPlot7560 <no-reply@tchallon.fr>
Date: Wed, 29 May 2024 19:11:37 +0200
Subject: [PATCH] implement PlayerCreation & PlayerDataSave as async events

---
 src/Server.php                                | 50 +++++++++++
 src/command/defaults/SaveCommand.php          | 27 +++++-
 src/event/AsyncEvent.php                      |  6 ++
 src/event/player/PlayerCreationAsyncEvent.php | 89 +++++++++++++++++++
 src/event/player/PlayerDataSaveAsyncEvent.php | 45 ++++++++++
 src/player/Player.php                         |  8 ++
 src/world/WorldManager.php                    | 38 ++++++--
 7 files changed, 254 insertions(+), 9 deletions(-)
 create mode 100644 src/event/player/PlayerCreationAsyncEvent.php
 create mode 100644 src/event/player/PlayerDataSaveAsyncEvent.php

diff --git a/src/Server.php b/src/Server.php
index a34349bb5cd..8e4a812b924 100644
--- a/src/Server.php
+++ b/src/Server.php
@@ -38,8 +38,11 @@
 use pocketmine\crash\CrashDumpRenderer;
 use pocketmine\entity\EntityDataHelper;
 use pocketmine\entity\Location;
+use pocketmine\event\AsyncEvent;
 use pocketmine\event\HandlerListManager;
+use pocketmine\event\player\PlayerCreationAsyncEvent;
 use pocketmine\event\player\PlayerCreationEvent;
+use pocketmine\event\player\PlayerDataSaveAsyncEvent;
 use pocketmine\event\player\PlayerDataSaveEvent;
 use pocketmine\event\player\PlayerLoginEvent;
 use pocketmine\event\server\CommandEvent;
@@ -530,6 +533,31 @@ public function getOfflinePlayerData(string $name) : ?CompoundTag{
 		});
 	}
 
+	/**
+	 * @return Promise<null>
+	 */
+	public function saveOfflinePlayerDataAsync(string $name, CompoundTag $nbtTag) : Promise{
+		$ev = new PlayerDataSaveAsyncEvent($nbtTag, $name, $this->getPlayerExact($name));
+		if(!$this->shouldSavePlayerData()){
+			$ev->cancel();
+		}
+		$resolver = new PromiseResolver();
+
+		$ev->call()->onCompletion(
+			function (PlayerDataSaveAsyncEvent $event) use ($name, $resolver) : void{
+				if($event->isCancelled()){
+					$resolver->reject();
+					return;
+				}
+				$this->saveOfflinePlayerData($name, $event->getSaveData());
+				$resolver->resolve(null);
+			},
+			fn() => $this->logger->debug("Cancelled saving player data for $name")
+		);
+
+		return $resolver->getPromise();
+	}
+
 	public function saveOfflinePlayerData(string $name, CompoundTag $nbtTag) : void{
 		$ev = new PlayerDataSaveEvent($nbtTag, $name, $this->getPlayerExact($name));
 		if(!$this->shouldSavePlayerData()){
@@ -554,7 +582,29 @@ public function saveOfflinePlayerData(string $name, CompoundTag $nbtTag) : void{
 	 * @phpstan-return Promise<Player>
 	 */
 	public function createPlayer(NetworkSession $session, PlayerInfo $playerInfo, bool $authenticated, ?CompoundTag $offlinePlayerData) : Promise{
+		$globalResolver = new PromiseResolver();
+
+		$evAsync = new PlayerCreationAsyncEvent($session);
+		$evAsync->call()->onCompletion(
+			fn(PlayerCreationAsyncEvent $event) =>
+				$this->onCreatePlayer($event, $playerInfo, $authenticated, $offlinePlayerData)->onCompletion(
+					fn(Player $player) => $globalResolver->resolve($player),
+					fn() => $globalResolver->reject()
+				),
+			fn() => $globalResolver->reject()
+		);
+
+		return $globalResolver->getPromise();
+	}
+
+	/**
+	 * @phpstan-return Promise<Player>
+	 */
+	private function onCreatePlayer(PlayerCreationAsyncEvent $event, PlayerInfo $playerInfo, bool $authenticated, ?CompoundTag $offlinePlayerData) : Promise{
+		$session = $event->getNetworkSession();
 		$ev = new PlayerCreationEvent($session);
+		$ev->setBaseClass($event->getBaseClass());
+		$ev->setPlayerClass($event->getPlayerClass());
 		$ev->call();
 		$class = $ev->getPlayerClass();
 
diff --git a/src/command/defaults/SaveCommand.php b/src/command/defaults/SaveCommand.php
index 4e406e6a3ce..0b7b76dc8a2 100644
--- a/src/command/defaults/SaveCommand.php
+++ b/src/command/defaults/SaveCommand.php
@@ -27,6 +27,8 @@
 use pocketmine\command\CommandSender;
 use pocketmine\lang\KnownTranslationFactory;
 use pocketmine\permission\DefaultPermissionNames;
+use pocketmine\promise\Promise;
+use pocketmine\promise\PromiseResolver;
 use function microtime;
 use function round;
 
@@ -44,15 +46,32 @@ public function execute(CommandSender $sender, string $commandLabel, array $args
 		Command::broadcastCommandMessage($sender, KnownTranslationFactory::pocketmine_save_start());
 		$start = microtime(true);
 
+		$promises = [];
 		foreach($sender->getServer()->getOnlinePlayers() as $player){
-			$player->save();
+			$promises[] = $player->saveAsync();
 		}
 
-		foreach($sender->getServer()->getWorldManager()->getWorlds() as $world){
-			$world->save(true);
+		$resolver = new PromiseResolver();
+
+		if(count($promises) === 0){
+			$resolver->resolve(null);
+		} else {
+			Promise::all($promises)->onCompletion(
+				fn () => $resolver->resolve(null),
+				fn () => $resolver->reject()
+			);
 		}
 
-		Command::broadcastCommandMessage($sender, KnownTranslationFactory::pocketmine_save_success((string) round(microtime(true) - $start, 3)));
+		$resolver->getPromise()->onCompletion(
+			function () use ($sender, $start) : void {
+				foreach($sender->getServer()->getWorldManager()->getWorlds() as $world){
+					$world->save(true);
+				}
+
+				Command::broadcastCommandMessage($sender, KnownTranslationFactory::pocketmine_save_success((string) round(microtime(true) - $start, 3)));
+			},
+			fn() => Command::broadcastCommandMessage($sender, "§cUnable to save the server")
+		);
 
 		return true;
 	}
diff --git a/src/event/AsyncEvent.php b/src/event/AsyncEvent.php
index 12337602473..c3d2ef9d73f 100644
--- a/src/event/AsyncEvent.php
+++ b/src/event/AsyncEvent.php
@@ -150,6 +150,12 @@ private function callPriority(int $priority) : Promise{
 	private function waitForPromises() : Promise{
 		$array = $this->promises->toArray();
 		$this->promises->clear();
+		if(count($array) === 0){
+			$resolver = new PromiseResolver();
+			$resolver->resolve([]);
+
+			return $resolver->getPromise();
+		}
 
 		return Promise::all($array);
 	}
diff --git a/src/event/player/PlayerCreationAsyncEvent.php b/src/event/player/PlayerCreationAsyncEvent.php
new file mode 100644
index 00000000000..37b1bdf7fa9
--- /dev/null
+++ b/src/event/player/PlayerCreationAsyncEvent.php
@@ -0,0 +1,89 @@
+<?php
+
+namespace pocketmine\event\player;
+
+use pocketmine\event\AsyncEvent;
+use pocketmine\network\mcpe\NetworkSession;
+use pocketmine\player\Player;
+use pocketmine\utils\Utils;
+
+/**
+ * Allows the use of custom Player classes. This enables overriding built-in Player methods to change behaviour that is
+ * not possible to alter any other way.
+ *
+ * You probably don't need this event, and found your way here because you looked at some code in an old plugin that
+ * abused it (very common). Instead of using custom player classes, you should consider making session classes instead.
+ *
+ * @see https://github.com/pmmp/SessionsDemo
+ *
+ * This event is a power-user feature, and multiple plugins using it at the same time will conflict and break unless
+ * they've been designed to work together. This means that it's only usually useful in private plugins.
+ *
+ * WARNING: This should NOT be used for adding extra functions or properties. This is intended for **overriding existing
+ * core behaviour**, and should only be used if you know EXACTLY what you're doing.
+ * Custom player classes may break in any update without warning. This event isn't much more than glorified reflection.
+ */
+class PlayerCreationAsyncEvent extends AsyncEvent{
+	/** @phpstan-var class-string<Player> */
+	private string $baseClass = Player::class;
+	/** @phpstan-var class-string<Player> */
+	private string $playerClass = Player::class;
+
+	public function __construct(private NetworkSession $session){}
+
+	public function getNetworkSession() : NetworkSession{
+		return $this->session;
+	}
+
+	public function getAddress() : string{
+		return $this->session->getIp();
+	}
+
+	public function getPort() : int{
+		return $this->session->getPort();
+	}
+
+	/**
+	 * Returns the base class that the final player class must extend.
+	 *
+	 * @phpstan-return class-string<Player>
+	 */
+	public function getBaseClass() : string{
+		return $this->baseClass;
+	}
+
+	/**
+	 * Sets the class that the final player class must extend.
+	 * The new base class must be a subclass of the current base class.
+	 * This can (perhaps) be used to limit the options for custom player classes provided by other plugins.
+	 *
+	 * @phpstan-param class-string<Player> $class
+	 */
+	public function setBaseClass(string $class) : void{
+		if(!is_a($class, $this->baseClass, true)){
+			throw new \RuntimeException("Base class $class must extend " . $this->baseClass);
+		}
+
+		$this->baseClass = $class;
+	}
+
+	/**
+	 * Returns the class that will be instantiated to create the player after the event.
+	 *
+	 * @phpstan-return class-string<Player>
+	 */
+	public function getPlayerClass() : string{
+		return $this->playerClass;
+	}
+
+	/**
+	 * Sets the class that will be instantiated to create the player after the event. The class must not be abstract,
+	 * and must be an instance of the base class.
+	 *
+	 * @phpstan-param class-string<Player> $class
+	 */
+	public function setPlayerClass(string $class) : void{
+		Utils::testValidInstance($class, $this->baseClass);
+		$this->playerClass = $class;
+	}
+}
\ No newline at end of file
diff --git a/src/event/player/PlayerDataSaveAsyncEvent.php b/src/event/player/PlayerDataSaveAsyncEvent.php
new file mode 100644
index 00000000000..9977bb48925
--- /dev/null
+++ b/src/event/player/PlayerDataSaveAsyncEvent.php
@@ -0,0 +1,45 @@
+<?php
+
+namespace pocketmine\event\player;
+
+use pocketmine\event\AsyncEvent;
+use pocketmine\event\Cancellable;
+use pocketmine\event\CancellableTrait;
+use pocketmine\nbt\tag\CompoundTag;
+use pocketmine\player\Player;
+
+class PlayerDataSaveAsyncEvent extends AsyncEvent implements Cancellable{
+	use CancellableTrait;
+
+	public function __construct(
+		protected CompoundTag $data,
+		protected string $playerName,
+		private ?Player $player
+	){}
+
+	/**
+	 * Returns the data to be written to disk as a CompoundTag
+	 */
+	public function getSaveData() : CompoundTag{
+		return $this->data;
+	}
+
+	public function setSaveData(CompoundTag $data) : void{
+		$this->data = $data;
+	}
+
+	/**
+	 * Returns the username of the player whose data is being saved. This is not necessarily an online player.
+	 */
+	public function getPlayerName() : string{
+		return $this->playerName;
+	}
+
+	/**
+	 * Returns the player whose data is being saved, if online.
+	 * If null, this data is for an offline player (possibly just disconnected).
+	 */
+	public function getPlayer() : ?Player{
+		return $this->player;
+	}
+}
\ No newline at end of file
diff --git a/src/player/Player.php b/src/player/Player.php
index 0cefbe71f22..d7135457cef 100644
--- a/src/player/Player.php
+++ b/src/player/Player.php
@@ -118,6 +118,7 @@
 use pocketmine\permission\PermissibleBase;
 use pocketmine\permission\PermissibleDelegateTrait;
 use pocketmine\player\chat\StandardChatFormatter;
+use pocketmine\promise\Promise;
 use pocketmine\Server;
 use pocketmine\ServerProperties;
 use pocketmine\timings\Timings;
@@ -2345,6 +2346,13 @@ public function save() : void{
 		$this->server->saveOfflinePlayerData($this->username, $this->getSaveData());
 	}
 
+	/**
+	 * @return Promise<null>
+	 */
+	public function saveAsync() : Promise {
+		return $this->server->saveOfflinePlayerDataAsync($this->username, $this->getSaveData());
+	}
+
 	protected function onDeath() : void{
 		//Crafting grid must always be evacuated even if keep-inventory is true. This dumps the contents into the
 		//main inventory and drops the rest on the ground.
diff --git a/src/world/WorldManager.php b/src/world/WorldManager.php
index ff603a2dfcb..0b02dcc6a33 100644
--- a/src/world/WorldManager.php
+++ b/src/world/WorldManager.php
@@ -29,6 +29,8 @@
 use pocketmine\event\world\WorldUnloadEvent;
 use pocketmine\lang\KnownTranslationFactory;
 use pocketmine\player\ChunkSelector;
+use pocketmine\promise\Promise;
+use pocketmine\promise\PromiseResolver;
 use pocketmine\Server;
 use pocketmine\world\format\Chunk;
 use pocketmine\world\format\io\exception\CorruptedWorldException;
@@ -356,9 +358,17 @@ public function tick(int $currentTick) : void{
 			$this->autoSaveTicker = 0;
 			$this->server->getLogger()->debug("[Auto Save] Saving worlds...");
 			$start = microtime(true);
-			$this->doAutoSave();
-			$time = microtime(true) - $start;
-			$this->server->getLogger()->debug("[Auto Save] Save completed in " . ($time >= 1 ? round($time, 3) . "s" : round($time * 1000) . "ms"));
+			$this->doAutoSave()->onCompletion(
+				function () use ($start) : void{
+					$time = microtime(true) - $start;
+					$this->server->getLogger()->debug("[Auto Save] Save completed in " . ($time >= 1 ? round($time, 3) . "s" : round($time * 1000) . "ms"));
+				},
+				function () use ($start) : void{
+					$this->server->getLogger()->error("[Auto Save] Save failed" );
+					$time = microtime(true) - $start;
+					$this->server->getLogger()->debug("[Auto Save] Save completed in " . ($time >= 1 ? round($time, 3) . "s" : round($time * 1000) . "ms"));
+				}
+			);
 		}
 	}
 
@@ -387,14 +397,32 @@ public function setAutoSaveInterval(int $autoSaveTicks) : void{
 		$this->autoSaveTicks = $autoSaveTicks;
 	}
 
-	private function doAutoSave() : void{
+	/**
+	 * @return Promise<null>
+	 */
+	private function doAutoSave() : Promise{
+		$promises = [];
 		foreach($this->worlds as $world){
 			foreach($world->getPlayers() as $player){
 				if($player->spawned){
-					$player->save();
+					$promises[] = $player->saveAsync();
 				}
 			}
 			$world->save(false);
 		}
+
+		$resolver = new PromiseResolver();
+
+		if(count($promises) === 0){
+			$resolver->resolve(null);
+		} else {
+			Promise::all($promises)
+				->onCompletion(
+					fn() => $resolver->resolve(null),
+					fn() => $resolver->reject()
+				);
+		}
+
+		return $resolver->getPromise();
 	}
 }