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;
}