diff --git a/README.md b/README.md index d3a6c82..207c853 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,10 @@ Smaccer is a powerful and easy-to-use PocketMine-MP plugin designed for managing - **Teleport**: Quickly teleport to NPCs or move other players to NPCs. - **Customizable Configuration**: Tailor the plugin's settings to fit your needs without restarting the server. - **Manage Permissions**: Fine-tune permissions to control who can interact with and manage NPCs. +- **Server Queries**: Check the status of remote servers, including player counts and server uptime. +- **World Queries**: Monitor player counts and world statuses across multiple worlds. +- **Customizable Messages**: Define and customize the display messages for server and world queries through configuration files. +- **Asynchronous Tasks**: Perform server queries asynchronously to avoid blocking the main server thread. ## Commands @@ -56,11 +60,39 @@ Smaccer offers a customizable configuration to tailor the NPC settings to your p # Smaccer Configuration # Do not change this (Only for internal use)! -config-version: 1.1 +config-version: 1.2 # Enable or disable the auto update checker notifier. update_notifier: true +# World Query Message Formats +# Customize how world information is displayed in the nametag. + +# When all specified worlds are loaded. +# Placeholders: +# - {world_names}: Comma-separated list of loaded world names. +# - {count}: Total player count across loaded worlds. +world_message_format: "§aWorlds: §b{world_names} §a| Players: §e{count}" + +# When some or all specified worlds are not loaded. +# Placeholders: +# - {world_names}: Comma-separated list of loaded world names. +# - {not_loaded_worlds}: Comma-separated list of worlds not loaded. +# - {count}: Total player count across loaded worlds. +world_not_loaded_format: "§cWorlds: §b{world_names} §c| Not Loaded: §7{not_loaded_worlds} §c| Players: §e{count}" + +# Server Query Message Formats +# Customize how server information is displayed in the nametag. + +# When the server is online. +# Placeholders: +# - {online}: Current number of players online. +# - {max_online}: Maximum number of players allowed online. +server_online_format: "§aServer: §b{online}§a/§b{max_online} §aonline" + +# When the server is offline. +server_offline_format: "§cServer: Offline" + # Default settings for NPCs. npc-default-settings: # Cooldown settings for NPC commands. @@ -125,6 +157,7 @@ npc-default-settings: + ## Upcoming Features diff --git a/resources/config.yml b/resources/config.yml index c361e32..10d2112 100644 --- a/resources/config.yml +++ b/resources/config.yml @@ -1,11 +1,39 @@ # Smaccer Configuration # Do not change this (Only for internal use)! -config-version: 1.1 +config-version: 1.2 # Enable or disable the auto update checker notifier. update_notifier: true +# World Query Message Formats +# Customize how world information is displayed in the nametag. + +# When all specified worlds are loaded. +# Placeholders: +# - {world_names}: Comma-separated list of loaded world names. +# - {count}: Total player count across loaded worlds. +world_message_format: "§aWorlds: §b{world_names} §a| Players: §e{count}" + +# When some or all specified worlds are not loaded. +# Placeholders: +# - {world_names}: Comma-separated list of loaded world names. +# - {not_loaded_worlds}: Comma-separated list of worlds not loaded. +# - {count}: Total player count across loaded worlds. +world_not_loaded_format: "§cWorlds: §b{world_names} §c| Not Loaded: §7{not_loaded_worlds} §c| Players: §e{count}" + +# Server Query Message Formats +# Customize how server information is displayed in the nametag. + +# When the server is online. +# Placeholders: +# - {online}: Current number of players online. +# - {max_online}: Maximum number of players allowed online. +server_online_format: "§aServer: §b{online}§a/§b{max_online} §aonline" + +# When the server is offline. +server_offline_format: "§cServer: Offline" + # Default settings for NPCs. npc-default-settings: # Cooldown settings for NPC commands. diff --git a/src/aiptu/smaccer/Smaccer.php b/src/aiptu/smaccer/Smaccer.php index 631002b..4196e25 100644 --- a/src/aiptu/smaccer/Smaccer.php +++ b/src/aiptu/smaccer/Smaccer.php @@ -31,16 +31,22 @@ use function is_bool; use function is_int; use function is_numeric; +use function is_string; class Smaccer extends PluginBase { use SingletonTrait; - private const CONFIG_VERSION = 1.1; + private const CONFIG_VERSION = 1.2; private bool $updateNotifierEnabled; private NPCDefaultSettings $npcDefaultSettings; private EmoteManager $emoteManager; + private string $worldMessageFormat; + private string $worldNotLoadedFormat; + private string $serverOnlineFormat; + private string $serverOfflineFormat; + protected function onEnable() : void { self::setInstance($this); @@ -86,6 +92,34 @@ private function loadConfig() : void { $this->updateNotifierEnabled = $updateNotifierEnabled; + $worldMessageFormat = $config->get('world_message_format'); + if (!is_string($worldMessageFormat)) { + throw new InvalidArgumentException("Invalid value for 'world_message_format'. Expected a string."); + } + + $this->worldMessageFormat = $worldMessageFormat; + + $worldNotLoadedFormat = $config->get('world_not_loaded_format'); + if (!is_string($worldNotLoadedFormat)) { + throw new InvalidArgumentException("Invalid value for 'world_not_loaded_format'. Expected a string."); + } + + $this->worldNotLoadedFormat = $worldNotLoadedFormat; + + $serverOnlineFormat = $config->get('server_online_format'); + if (!is_string($serverOnlineFormat)) { + throw new InvalidArgumentException("Invalid value for 'server_online_format'. Expected a string."); + } + + $this->serverOnlineFormat = $serverOnlineFormat; + + $serverOfflineFormat = $config->get('server_offline_format'); + if (!is_string($serverOfflineFormat)) { + throw new InvalidArgumentException("Invalid value for 'server_offline_format'. Expected a string."); + } + + $this->serverOfflineFormat = $serverOfflineFormat; + /** * @var array{ * commandCooldown: array{enabled: bool, value: float|int}, @@ -223,4 +257,20 @@ public function getEmoteManager() : EmoteManager { public function setEmoteManager(EmoteManager $emoteManager) : void { $this->emoteManager = $emoteManager; } + + public function getWorldMessageFormat() : string { + return $this->worldMessageFormat; + } + + public function getWorldNotLoadedFormat() : string { + return $this->worldNotLoadedFormat; + } + + public function getServerOnlineFormat() : string { + return $this->serverOnlineFormat; + } + + public function getServerOfflineFormat() : string { + return $this->serverOfflineFormat; + } } diff --git a/src/aiptu/smaccer/entity/EntitySmaccer.php b/src/aiptu/smaccer/entity/EntitySmaccer.php index 80ff4d6..ebb35ba 100644 --- a/src/aiptu/smaccer/entity/EntitySmaccer.php +++ b/src/aiptu/smaccer/entity/EntitySmaccer.php @@ -16,6 +16,7 @@ use aiptu\smaccer\entity\trait\CommandTrait; use aiptu\smaccer\entity\trait\CreatorTrait; use aiptu\smaccer\entity\trait\NametagTrait; +use aiptu\smaccer\entity\trait\QueryTrait; use aiptu\smaccer\entity\trait\RotationTrait; use aiptu\smaccer\entity\trait\VisibilityTrait; use aiptu\smaccer\entity\utils\EntityTag; @@ -30,6 +31,7 @@ abstract class EntitySmaccer extends Entity { use RotationTrait; use VisibilityTrait; use CommandTrait; + use QueryTrait; public function __construct(Location $location, ?CompoundTag $nbt = null) { if ($nbt instanceof CompoundTag) { @@ -49,6 +51,7 @@ protected function initEntity(CompoundTag $nbt) : void { $this->setNameTagVisible((bool) $nbt->getByte(EntityTag::NAMETAG_VISIBLE, 1)); $this->initializeVisibility($nbt); $this->setHasGravity((bool) $nbt->getByte(EntityTag::GRAVITY, 1)); + $this->initializeQuery($nbt); } public function saveNBT() : CompoundTag { @@ -61,6 +64,7 @@ public function saveNBT() : CompoundTag { $nbt->setByte(EntityTag::NAMETAG_VISIBLE, (int) $this->isNameTagVisible()); $this->saveVisibility($nbt); $nbt->setByte(EntityTag::GRAVITY, (int) $this->hasGravity()); + $this->saveQuery($nbt); return $nbt; } diff --git a/src/aiptu/smaccer/entity/HumanSmaccer.php b/src/aiptu/smaccer/entity/HumanSmaccer.php index 48be772..4350ba5 100644 --- a/src/aiptu/smaccer/entity/HumanSmaccer.php +++ b/src/aiptu/smaccer/entity/HumanSmaccer.php @@ -18,6 +18,7 @@ use aiptu\smaccer\entity\trait\EmoteTrait; use aiptu\smaccer\entity\trait\InventoryTrait; use aiptu\smaccer\entity\trait\NametagTrait; +use aiptu\smaccer\entity\trait\QueryTrait; use aiptu\smaccer\entity\trait\RotationTrait; use aiptu\smaccer\entity\trait\SkinTrait; use aiptu\smaccer\entity\trait\SlapBackTrait; @@ -38,6 +39,7 @@ class HumanSmaccer extends Human { use CommandTrait; use InventoryTrait; use SkinTrait; + use QueryTrait; public function __construct(Location $location, Skin $skin, ?CompoundTag $nbt = null) { if ($nbt instanceof CompoundTag) { @@ -59,6 +61,7 @@ protected function initEntity(CompoundTag $nbt) : void { $this->initializeSlapBack($nbt); $this->initializeEmote($nbt); $this->setHasGravity((bool) $nbt->getByte(EntityTag::GRAVITY, 1)); + $this->initializeQuery($nbt); } public function saveNBT() : CompoundTag { @@ -73,6 +76,7 @@ public function saveNBT() : CompoundTag { $this->saveEmote($nbt); $this->saveSlapBack($nbt); $nbt->setByte(EntityTag::GRAVITY, (int) $this->hasGravity()); + $this->saveQuery($nbt); return $nbt; } diff --git a/src/aiptu/smaccer/entity/query/QueryHandler.php b/src/aiptu/smaccer/entity/query/QueryHandler.php new file mode 100644 index 0000000..66bf661 --- /dev/null +++ b/src/aiptu/smaccer/entity/query/QueryHandler.php @@ -0,0 +1,187 @@ +}> */ + private array $queries = []; + private int $nextId = 1; + + public function __construct(CompoundTag $nbt) { + $queriesTag = $nbt->getTag(self::NBT_QUERIES_KEY); + if ($queriesTag instanceof ListTag) { + foreach ($queriesTag as $tag) { + if ($tag instanceof CompoundTag) { + $type = $tag->getString(self::NBT_TYPE_KEY); + if ($type === self::TYPE_SERVER) { + $ip = $tag->getString(self::NBT_IP_KEY); + $port = $tag->getInt(self::NBT_PORT_KEY); + $this->addServerQuery($ip, $port); + } elseif ($type === self::TYPE_WORLD) { + $worldName = $tag->getString(self::NBT_WORLD_NAME_KEY); + $this->addWorldQuery($worldName); + } + } + } + } + } + + /** + * Adds a server query (IP or domain and port) and returns its ID. If invalid, returns null. + */ + public function addServerQuery(string $ipOrDomain, int $port) : ?int { + $ipOrDomain = trim($ipOrDomain); + + if (!Utils::isValidIpOrDomain($ipOrDomain) || !Utils::isValidPort($port)) { + return null; + } + + $id = $this->nextId++; + $this->queries[$id] = [ + 'type' => self::TYPE_SERVER, + 'value' => [self::NBT_IP_KEY => $ipOrDomain, self::NBT_PORT_KEY => $port], + ]; + return $id; + } + + /** + * Adds a world query (world name) and returns its ID. If invalid, returns null. + */ + public function addWorldQuery(string $worldName) : ?int { + $worldName = trim($worldName); + + if ($worldName === '') { + return null; + } + + $id = $this->nextId++; + $this->queries[$id] = [ + 'type' => self::TYPE_WORLD, + 'value' => [self::NBT_WORLD_NAME_KEY => $worldName], + ]; + return $id; + } + + /** + * Edits an existing server query identified by its ID. + */ + public function editServerQuery(int $id, string $newIpOrDomain, int $newPort) : bool { + $newIpOrDomain = trim($newIpOrDomain); + + if (!$this->exists($id) || !Utils::isValidIpOrDomain($newIpOrDomain) || !Utils::isValidPort($newPort)) { + return false; + } + + $this->queries[$id] = [ + 'type' => self::TYPE_SERVER, + 'value' => [self::NBT_IP_KEY => $newIpOrDomain, self::NBT_PORT_KEY => $newPort], + ]; + return true; + } + + public function editWorldQuery(int $id, string $newWorldName) : bool { + $newWorldName = trim($newWorldName); + + if (!$this->exists($id) || $newWorldName === '') { + return false; + } + + $this->queries[$id] = [ + 'type' => self::TYPE_WORLD, + 'value' => [self::NBT_WORLD_NAME_KEY => $newWorldName], + ]; + return true; + } + + /** + * Checks if a specific query exists in the NBT data. + */ + public function queryExistsInNBT(CompoundTag $nbt, string $type, string $queryValue, ?int $port = null) : bool { + $queryValue = trim($queryValue); + + $queriesTag = $nbt->getTag(self::NBT_QUERIES_KEY); + if ($queriesTag instanceof ListTag) { + foreach ($queriesTag as $tag) { + if ($tag instanceof CompoundTag && $tag->getString(self::NBT_TYPE_KEY) === $type) { + switch ($type) { + case self::TYPE_SERVER: + if ($tag->getString(self::NBT_IP_KEY) === $queryValue && $tag->getInt(self::NBT_PORT_KEY) === $port) { + return true; + } + + break; + case self::TYPE_WORLD: + if ($tag->getString(self::NBT_WORLD_NAME_KEY) === $queryValue) { + return true; + } + + break; + } + } + } + } + + return false; + } + + /** + * Checks if a query with the given ID exists. + */ + public function exists(int $id) : bool { + return isset($this->queries[$id]); + } + + /** + * Retrieves all queries. + * + * @return array}> + */ + public function getAll() : array { + return $this->queries; + } + + /** + * Removes the query with the specified ID. + */ + public function removeById(int $id) : bool { + if ($this->exists($id)) { + unset($this->queries[$id]); + return true; + } + + return false; + } + + /** + * Clears all queries. + */ + public function clearAll() : void { + $this->queries = []; + $this->nextId = 1; + } +} diff --git a/src/aiptu/smaccer/entity/query/QueryInfo.php b/src/aiptu/smaccer/entity/query/QueryInfo.php new file mode 100644 index 0000000..5d5b932 --- /dev/null +++ b/src/aiptu/smaccer/entity/query/QueryInfo.php @@ -0,0 +1,104 @@ +query['type']) { + case QueryHandler::TYPE_SERVER: + return $this->fetchServerQueryMessage(); + case QueryHandler::TYPE_WORLD: + return $this->generateWorldMessage(); + default: + return null; + } + } + + private function fetchServerQueryMessage() : string { + $key = $this->getCacheKey(); + + $this->scheduleServerQuery(); + + return self::$latestResults[$key] ?? 'Querying server...'; + } + + private function scheduleServerQuery() : void { + $server = Server::getInstance(); + $taskData = [ + 'ip' => $this->query['value'][QueryHandler::NBT_IP_KEY], + 'port' => (int) $this->query['value'][QueryHandler::NBT_PORT_KEY], + 'messages' => [ + 'online' => $this->query['onlineMessage'], + 'offline' => $this->query['offlineMessage'], + ], + 'cacheKey' => $this->getCacheKey(), + ]; + + $task = new QueryServerTask([$taskData]); + $server->getAsyncPool()->submitTask($task); + } + + private function getCacheKey() : string { + return "{$this->query['value'][QueryHandler::NBT_IP_KEY]}:{$this->query['value'][QueryHandler::NBT_PORT_KEY]}"; + } + + private function generateWorldMessage() : string { + $worldNames = explode('&', $this->query['value'][QueryHandler::NBT_WORLD_NAME_KEY]); + $totalPlayerCount = 0; + $loadedWorlds = []; + $notLoadedWorlds = []; + + foreach ($worldNames as $worldName) { + $world = Server::getInstance()->getWorldManager()->getWorldByName($worldName); + if ($world !== null) { + $totalPlayerCount += count($world->getPlayers()); + $loadedWorlds[] = $worldName; + } else { + $notLoadedWorlds[] = $worldName; + } + } + + $loadedWorldsString = implode(', ', $loadedWorlds); + $notLoadedWorldsString = implode(', ', $notLoadedWorlds); + + if (count($notLoadedWorlds) > 0) { + return str_replace( + ['{world_names}', '{count}', '{not_loaded_worlds}'], + [$loadedWorldsString, (string) $totalPlayerCount, $notLoadedWorldsString], + $this->query['worldNotLoadedFormat'] + ); + } + + return str_replace( + ['{world_names}', '{count}'], + [$loadedWorldsString, (string) $totalPlayerCount], + $this->query['worldMessageFormat'] + ); + } + + public static function updateCache(string $key, string $result) : void { + self::$latestResults[$key] = $result; + } +} diff --git a/src/aiptu/smaccer/entity/trait/NametagTrait.php b/src/aiptu/smaccer/entity/trait/NametagTrait.php index 435f12e..5bef210 100644 --- a/src/aiptu/smaccer/entity/trait/NametagTrait.php +++ b/src/aiptu/smaccer/entity/trait/NametagTrait.php @@ -52,9 +52,7 @@ public function sendData(?array $targets, ?array $data = null) : void { } public function applyNametag(?string $nametag, Player $player) : string { - if ($nametag === null) { - $nametag = $this->getNameTag(); - } + $nametag ??= $this->getNameTag(); $vars = [ '{player}' => $player->getName(), diff --git a/src/aiptu/smaccer/entity/trait/QueryTrait.php b/src/aiptu/smaccer/entity/trait/QueryTrait.php new file mode 100644 index 0000000..dac794e --- /dev/null +++ b/src/aiptu/smaccer/entity/trait/QueryTrait.php @@ -0,0 +1,138 @@ +queryHandler = new QueryHandler($nbt); + } + + public function saveQuery(CompoundTag $nbt) : void { + $queriesTag = new ListTag(); + + foreach ($this->queryHandler->getAll() as $query) { + $queryTag = new CompoundTag(); + + $queryTag->setString(QueryHandler::NBT_TYPE_KEY, $query['type']); + + if ($query['type'] === QueryHandler::TYPE_SERVER) { + $queryTag->setString(QueryHandler::NBT_IP_KEY, (string) $query['value'][QueryHandler::NBT_IP_KEY]); + $queryTag->setInt(QueryHandler::NBT_PORT_KEY, (int) $query['value'][QueryHandler::NBT_PORT_KEY]); + } elseif ($query['type'] === QueryHandler::TYPE_WORLD) { + $queryTag->setString(QueryHandler::NBT_WORLD_NAME_KEY, (string) $query['value'][QueryHandler::NBT_WORLD_NAME_KEY]); + } + + $queriesTag->push($queryTag); + } + + $nbt->setTag(QueryHandler::NBT_QUERIES_KEY, $queriesTag); + } + + public function onUpdate(int $currentTick) : bool { + $result = parent::onUpdate($currentTick); + + $this->updateNameTag(); + + return $result; + } + + public function updateNameTag() : void { + $queries = $this->queryHandler->getAll(); + $currentNameTag = $this->getNameTag(); + $newNameTagParts = []; + + $nonQueryPart = strtok($currentNameTag, "\n"); + + if ($nonQueryPart !== false) { + $newNameTagParts[] = $nonQueryPart; + } + + usort($queries, function ($a, $b) { + if ($a['type'] === $b['type']) { + return 0; + } + + return $a['type'] === QueryHandler::TYPE_SERVER ? -1 : 1; + }); + + foreach ($queries as $query) { + $queryInfo = new QueryInfo([ + 'type' => $query['type'], + 'value' => $query['value'], + 'onlineMessage' => Smaccer::getInstance()->getServerOnlineFormat(), + 'offlineMessage' => Smaccer::getInstance()->getServerOfflineFormat(), + 'worldMessageFormat' => Smaccer::getInstance()->getWorldMessageFormat(), + 'worldNotLoadedFormat' => Smaccer::getInstance()->getWorldNotLoadedFormat(), + ]); + + $nameTagPart = $queryInfo->getNameTagPart(); + if ($nameTagPart !== null) { + $newNameTagParts[] = $nameTagPart; + } + } + + $newNameTag = implode("\n", $newNameTagParts); + if ($newNameTag !== $currentNameTag) { + $this->setNameTag($newNameTag); + } + } + + public function getQueryHandler() : QueryHandler { + return $this->queryHandler; + } + + public function addServerQuery(string $ipOrDomain, int $port) : ?int { + return $this->queryHandler->addServerQuery($ipOrDomain, $port); + } + + public function addWorldQuery(string $worldName) : ?int { + return $this->queryHandler->addWorldQuery($worldName); + } + + public function editServerQuery(int $id, string $newIpOrDomain, int $newPort) : bool { + return $this->queryHandler->editServerQuery($id, $newIpOrDomain, $newPort); + } + + public function editWorldQuery(int $id, string $newWorldName) : bool { + return $this->queryHandler->editWorldQuery($id, $newWorldName); + } + + public function queryExistsInNBT(CompoundTag $nbt, string $type, string $queryValue, ?int $port = null) : bool { + return $this->queryHandler->queryExistsInNBT($nbt, $type, $queryValue, $port); + } + + public function getQueries() : array { + return $this->queryHandler->getAll(); + } + + public function removeQueryById(int $id) : bool { + return $this->queryHandler->removeById($id); + } + + public function clearQueries() : void { + $this->queryHandler->clearAll(); + } +} diff --git a/src/aiptu/smaccer/tasks/QueryServerTask.php b/src/aiptu/smaccer/tasks/QueryServerTask.php new file mode 100644 index 0000000..0d003af --- /dev/null +++ b/src/aiptu/smaccer/tasks/QueryServerTask.php @@ -0,0 +1,81 @@ + */ + private ThreadSafeArray $taskData; + + /** + * @param array $taskData + */ + public function __construct(array $taskData) { + $this->taskData = ThreadSafeArray::fromArray($taskData); + } + + public function onRun() : void { + $resultData = []; + foreach ($this->taskData as $data) { + /** @var TaskData $data */ + try { + $queryData = PMQuery::query($data['ip'], $data['port']); + $onlinePlayers = $queryData['Players']; + $maxOnlinePlayers = $queryData['MaxPlayers']; + $resultMessage = str_replace( + ['{online}', '{max_online}'], + [$onlinePlayers, $maxOnlinePlayers], + $data['messages']['online'] + ); + } catch (PmQueryException $e) { + $resultMessage = $data['messages']['offline']; + } + + $resultData[] = [ + 'cacheKey' => $data['cacheKey'], + 'resultMessage' => $resultMessage, + ]; + } + + $this->setResult($resultData); + } + + public function onCompletion() : void { + $result = $this->getResult(); + if (is_array($result)) { + foreach ($result as $data) { + /** @var array{cacheKey: string, resultMessage: string} $data */ + QueryInfo::updateCache($data['cacheKey'], $data['resultMessage']); + } + } + } +} diff --git a/src/aiptu/smaccer/utils/FormManager.php b/src/aiptu/smaccer/utils/FormManager.php index f95e668..4713cd2 100644 --- a/src/aiptu/smaccer/utils/FormManager.php +++ b/src/aiptu/smaccer/utils/FormManager.php @@ -18,6 +18,7 @@ use aiptu\smaccer\entity\EntitySmaccer; use aiptu\smaccer\entity\HumanSmaccer; use aiptu\smaccer\entity\NPCData; +use aiptu\smaccer\entity\query\QueryHandler; use aiptu\smaccer\entity\SmaccerHandler; use aiptu\smaccer\entity\utils\EntityTag; use aiptu\smaccer\entity\utils\EntityVisibility; @@ -35,6 +36,7 @@ use pocketmine\entity\Entity; use pocketmine\player\Player; use pocketmine\utils\TextFormat; +use function array_filter; use function array_keys; use function array_map; use function array_merge; @@ -45,6 +47,7 @@ use function implode; use function is_a; use function is_bool; +use function is_numeric; use function is_string; use function min; use function ucfirst; @@ -187,7 +190,7 @@ public static function handleCreateNPCResponse(Player $player, CustomFormRespons $visibility = $values[4]; $gravityEnabled = $values[5]; - if (!is_string($nameTag) || !is_string($scaleStr) || !is_bool($rotationEnabled) || !is_bool($nameTagVisible) || !is_string($visibility) || !is_bool($gravityEnabled)) { + if (!is_string($nameTag) || !is_numeric($scaleStr) || !is_bool($rotationEnabled) || !is_bool($nameTagVisible) || !is_string($visibility) || !is_bool($gravityEnabled)) { $player->sendMessage(TextFormat::RED . 'Invalid form values.'); return; } @@ -309,17 +312,19 @@ public static function sendEditMenuForm(Player $player, Entity $npc) : void { 'Commands', 'Teleport NPC to Player', 'Teleport Player to NPC', + 'Query Settings', ], fn (Player $player, Button $selected) => match ($selected->getValue()) { 0 => self::sendEditNPCForm($player, $npc), 1 => self::sendEditCommandsForm($player, $npc), 2 => self::sendTeleportOptionsForm($player, $npc, self::TELEPORT_NPC_TO_PLAYER), 3 => self::sendTeleportOptionsForm($player, $npc, self::TELEPORT_PLAYER_TO_NPC), - 4 => self::handleEmoteSelection($player, $npc), - 5 => self::sendEditSkinSettingsForm($player, $npc), - 6 => self::sendArmorSettingsForm($player, $npc), - 7 => self::equipHeldItem($player, $npc), - 8 => self::equipOffHandItem($player, $npc), + 4 => self::sendQueryManagementForm($player, $npc), + 5 => self::handleEmoteSelection($player, $npc), + 6 => self::sendEditSkinSettingsForm($player, $npc), + 7 => self::sendArmorSettingsForm($player, $npc), + 8 => self::equipHeldItem($player, $npc), + 9 => self::equipOffHandItem($player, $npc), default => $player->sendMessage(TextFormat::RED . 'Invalid option selected.'), } ); @@ -378,7 +383,7 @@ function (Player $player, CustomFormResponse $response) use ($npc) : void { $visibility = $values[4]; $gravityEnabled = $values[5]; - if (!is_string($nameTag) || !is_string($scaleStr) || !is_bool($rotationEnabled) || !is_bool($nameTagVisible) || !is_string($visibility) || !is_bool($gravityEnabled)) { + if (!is_string($nameTag) || !is_numeric($scaleStr) || !is_bool($rotationEnabled) || !is_bool($nameTagVisible) || !is_string($visibility) || !is_bool($gravityEnabled)) { $player->sendMessage(TextFormat::RED . 'Invalid form values.'); return; } @@ -997,4 +1002,227 @@ public static function sendNPCListForm(Player $player) : void { $player->sendForm(new MenuForm('List NPCs', $content)); } + + public static function sendQueryManagementForm(Player $player, Entity $npc) : void { + if (!$npc instanceof EntitySmaccer && !$npc instanceof HumanSmaccer) { + return; + } + + $form = MenuForm::withOptions( + 'Manage Queries', + 'Select a query type:', + [ + 'Add Server Query', + 'Add World Query', + 'Edit/Remove Server Query', + 'Edit/Remove World Query', + ], + fn (Player $player, Button $selected) => match ($selected->text) { + 'Add Server Query' => self::sendAddServerQueryForm($player, $npc), + 'Add World Query' => self::sendAddWorldQueryForm($player, $npc), + 'Edit/Remove Server Query' => self::sendEditRemoveQueryForm($player, $npc, QueryHandler::TYPE_SERVER), + 'Edit/Remove World Query' => self::sendEditRemoveQueryForm($player, $npc, QueryHandler::TYPE_WORLD), + default => $player->sendMessage(TextFormat::RED . 'Invalid option selected.'), + } + ); + + $player->sendForm($form); + } + + public static function sendAddServerQueryForm(Player $player, Entity $npc) : void { + if (!$npc instanceof EntitySmaccer && !$npc instanceof HumanSmaccer) { + return; + } + + $player->sendForm( + new CustomForm( + 'Add Server Query', + [ + new Input('Enter IP/Domain', 'ip_or_domain'), + new Input('Enter Port', 'port'), + ], + function (Player $player, CustomFormResponse $response) use ($npc) : void { + $values = $response->getValues(); + + $ipOrDomain = $values[0]; + $port = $values[1]; + + if (!is_string($ipOrDomain) || !is_numeric($port)) { + $player->sendMessage(TextFormat::RED . 'Invalid form values.'); + return; + } + + if ($npc->getQueryHandler()->addServerQuery($ipOrDomain, (int) $port) !== null) { + $player->sendMessage(TextFormat::GREEN . "Server query added to NPC {$npc->getName()}."); + } else { + $player->sendMessage(TextFormat::RED . "Failed to add server query for NPC {$npc->getName()}."); + } + } + ) + ); + } + + public static function sendAddWorldQueryForm(Player $player, Entity $npc) : void { + if (!$npc instanceof EntitySmaccer && !$npc instanceof HumanSmaccer) { + return; + } + + $player->sendForm( + new CustomForm( + 'Add World Query', + [ + new Input('Enter world name', 'world_name'), + ], + function (Player $player, CustomFormResponse $response) use ($npc) : void { + $worldName = $response->getInput()->getValue(); + + if ($npc->getQueryHandler()->addWorldQuery($worldName) !== null) { + $player->sendMessage(TextFormat::GREEN . "World query added to NPC {$npc->getName()}."); + } else { + $player->sendMessage(TextFormat::RED . "Failed to add world query for NPC {$npc->getName()}."); + } + } + ) + ); + } + + public static function sendEditRemoveQueryForm(Player $player, Entity $npc, string $queryType) : void { + if (!$npc instanceof EntitySmaccer && !$npc instanceof HumanSmaccer) { + return; + } + + $queries = $npc->getQueryHandler()->getAll(); + $buttons = array_values(array_filter(array_map( + fn ($id, $data) => $queryType === $data['type'] + ? new Button( + $data['type'] === QueryHandler::TYPE_SERVER + ? "IP: {$data['value']['ip']} Port: {$data['value']['port']}" + : "World: {$data['value']['world_name']}" + ) + : null, + array_keys($queries), + $queries + ))); + + if (count($buttons) === 0) { + $player->sendMessage(TextFormat::RED . 'No queries found for the selected type.'); + return; + } + + $player->sendForm( + new MenuForm( + 'Edit/Remove Query', + 'Select a query to edit/remove:', + $buttons, + function (Player $player, Button $selected) use ($npc, $queries) : void { + $selectedText = $selected->text; + foreach ($queries as $id => $data) { + $expectedText = $data['type'] === QueryHandler::TYPE_SERVER + ? "IP: {$data['value']['ip']} Port: {$data['value']['port']}" + : "World: {$data['value']['world_name']}"; + if ($expectedText === $selectedText) { + self::handleQuerySelection($player, $npc, $id, $data['type'], $data['value']); + return; + } + } + + $player->sendMessage(TextFormat::RED . 'Failed to match the selected query.'); + } + ) + ); + } + + public static function handleQuerySelection(Player $player, Entity $npc, int $queryId, string $queryType, array $queryValue) : void { + if (!$npc instanceof EntitySmaccer && !$npc instanceof HumanSmaccer) { + return; + } + + $player->sendForm( + MenuForm::withOptions( + 'Edit or Remove Query', + $queryType === QueryHandler::TYPE_SERVER + ? "IP: {$queryValue['ip']} Port: {$queryValue['port']}" + : "World: {$queryValue['world_name']}", + [ + 'Edit', + 'Remove', + ], + fn (Player $player, Button $selected) => match ($selected->getValue()) { + 0 => self::sendEditQueryForm($player, $npc, $queryId, $queryType, $queryValue), + 1 => self::confirmRemoveQuery($player, $npc, $queryId), + default => $player->sendMessage(TextFormat::RED . 'Invalid option selected.'), + } + ) + ); + } + + public static function sendEditQueryForm(Player $player, Entity $npc, int $queryId, string $queryType, array $queryValue) : void { + if (!$npc instanceof EntitySmaccer && !$npc instanceof HumanSmaccer) { + return; + } + + if ($queryType === QueryHandler::TYPE_SERVER) { + $player->sendForm( + new CustomForm( + 'Edit Server Query', + [ + new Input('Edit IP/Domain', 'ip_or_domain', $queryValue['ip']), + new Input('Edit Port', 'port', (string) $queryValue['port']), + ], + function (Player $player, CustomFormResponse $response) use ($npc, $queryId) : void { + $values = $response->getValues(); + + $newIpOrDomain = $values[0]; + $newPort = $values[1]; + + if (!is_string($newIpOrDomain) || !is_numeric($newPort)) { + $player->sendMessage(TextFormat::RED . 'Invalid form values.'); + return; + } + + if ($npc->getQueryHandler()->editServerQuery($queryId, $newIpOrDomain, (int) $newPort)) { + $player->sendMessage(TextFormat::GREEN . "Server query updated for NPC {$npc->getName()}."); + } else { + $player->sendMessage(TextFormat::RED . "Failed to update server query for NPC {$npc->getName()}."); + } + } + ) + ); + } else { + $player->sendForm( + new CustomForm( + 'Edit World Query', + [ + new Input('Edit world name', 'world_name', $queryValue['world_name']), + ], + function (Player $player, CustomFormResponse $response) use ($npc, $queryId) : void { + $newWorldName = $response->getInput()->getValue(); + + if ($npc->getQueryHandler()->editWorldQuery($queryId, $newWorldName)) { + $player->sendMessage(TextFormat::GREEN . "World query updated for NPC {$npc->getName()}."); + } else { + $player->sendMessage(TextFormat::RED . "Failed to update world query for NPC {$npc->getName()}."); + } + } + ) + ); + } + } + + public static function confirmRemoveQuery(Player $player, Entity $npc, int $queryId) : void { + if (!$npc instanceof EntitySmaccer && !$npc instanceof HumanSmaccer) { + return; + } + + $player->sendForm( + ModalForm::confirm( + 'Confirm Remove Query', + "Are you sure you want to remove this query from NPC: {$npc->getName()}?", + function (Player $player) use ($npc, $queryId) : void { + $npc->getQueryHandler()->removeById($queryId); + $player->sendMessage(TextFormat::GREEN . "Query removed from NPC {$npc->getName()}."); + } + ) + ); + } } diff --git a/src/aiptu/smaccer/utils/Utils.php b/src/aiptu/smaccer/utils/Utils.php index 68af45d..6672cd0 100644 --- a/src/aiptu/smaccer/utils/Utils.php +++ b/src/aiptu/smaccer/utils/Utils.php @@ -27,6 +27,9 @@ use function preg_replace; use function preg_split; use function str_replace; +use const FILTER_FLAG_HOSTNAME; +use const FILTER_VALIDATE_DOMAIN; +use const FILTER_VALIDATE_IP; use const FILTER_VALIDATE_URL; use const PREG_SPLIT_NO_EMPTY; @@ -85,6 +88,34 @@ public static function isValidUrl(string $url) : bool { return filter_var($url, FILTER_VALIDATE_URL) !== false; } + /** + * Validates the IP address or host name format. + */ + public static function isValidIpOrDomain(string $ipOrDomain) : bool { + return self::isValidIp($ipOrDomain) || self::isValidDomain($ipOrDomain); + } + + /** + * Validates the IP address format. + */ + public static function isValidIp(string $ip) : bool { + return filter_var($ip, FILTER_VALIDATE_IP) !== false; + } + + /** + * Validates the host name format. + */ + public static function isValidDomain(string $host) : bool { + return filter_var($host, FILTER_VALIDATE_DOMAIN, FILTER_FLAG_HOSTNAME) !== false; + } + + /** + * Validates the port number. + */ + public static function isValidPort(int $port) : bool { + return $port > 0 && $port <= 65535; + } + public static function isPngUrl(string $url) : bool { return preg_match('/^https?:\/\/.+\.(png)$/i', $url) === 1; }