diff --git a/data/sql/updates/pending_db_world/rev_1766254536399579700.sql b/data/sql/updates/pending_db_world/rev_1766254536399579700.sql
new file mode 100644
index 000000000..f65621714
--- /dev/null
+++ b/data/sql/updates/pending_db_world/rev_1766254536399579700.sql
@@ -0,0 +1,10 @@
+--
+DELETE FROM `command` WHERE `name` LIKE 'pooltools%';
+INSERT INTO `command` (`name`, `security`, `help`) VALUES
+('pooltools', 3, 'Syntax: .pooltools $subcommand\nTools for creating gameobject pools ingame.\nTo use pooltools, uncomment Appender.Dev and Logger.sql.dev in the worldserver config.'),
+('pooltools start', 3, 'Syntax: .pooltools start #description\nStarts a pooling session with the specified description.'),
+('pooltools def', 3, 'Syntax: .pooltools def #GameobjectID #Chance #GameobjectID #Chance (...)\nDefines the Gameobject entries to be detected along with their associated chances.'),
+('pooltools add', 3, 'Syntax: .pooltools add [radius]\nAdds nearby gameobjects to the pooling session. Default radius is 5y.'),
+('pooltools remove', 3, 'Syntax: .pooltools remove\nRemoves the last group from the pooling session.'),
+('pooltools end', 3, 'Syntax: .pooltools end\nLogs the current pooling session and clears it.'),
+('pooltools clear', 3, 'Syntax: .pooltools clear\nClears the current pooling session.');
diff --git a/src/server/apps/worldserver/worldserver.conf.dist b/src/server/apps/worldserver/worldserver.conf.dist
index 92ed0e3af..c34645af1 100644
--- a/src/server/apps/worldserver/worldserver.conf.dist
+++ b/src/server/apps/worldserver/worldserver.conf.dist
@@ -680,6 +680,7 @@ Appender.Server=2,5,0,Server.log,w
# Appender.GM=2,5,15,gm_%s.log
Appender.Errors=2,2,0,Errors.log,w
# Appender.DB=3,5,0
+# Appender.Dev=2,5,0,Dev.log,a
# Logger config values: Given a logger "name"
# Logger.name
@@ -804,7 +805,7 @@ Logger.spells.scripts=2,Console Errors
#Logger.spells.effect=4,Console Server
#Logger.spells.scripts=4,Console Server
#Logger.spells=4,Console Server
-#Logger.sql.dev=4,Console Server
+#Logger.sql.dev=4,Console Server Dev
#Logger.sql.driver=4,Console Server
#Logger.vehicles=4,Console Server
#Logger.warden=4,Console Server
diff --git a/src/server/scripts/Commands/cs_pooltools.cpp b/src/server/scripts/Commands/cs_pooltools.cpp
new file mode 100644
index 000000000..4bb4d448f
--- /dev/null
+++ b/src/server/scripts/Commands/cs_pooltools.cpp
@@ -0,0 +1,419 @@
+/*
+ * This file is part of the AzerothCore Project. See AUTHORS file for Copyright information
+ *
+ * This program is free software; you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation; either version 2 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful, but WITHOUT
+ * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
+ * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
+ * more details.
+ *
+ * You should have received a copy of the GNU General Public License along
+ * with this program. If not, see .
+ */
+
+#include "Cell.h"
+#include "CellImpl.h"
+#include "Chat.h"
+#include "CommandScript.h"
+#include "GameObject.h"
+#include "GridNotifiers.h"
+#include "GridNotifiersImpl.h"
+#include "MapMgr.h"
+#include "Player.h"
+#include "ScriptMgr.h"
+#include "Tokenize.h"
+
+using namespace Acore::ChatCommands;
+
+struct PoolTemplateItem
+{
+ uint32 Entry;
+ uint32 Chance;
+};
+
+// Represents one "spawn point" which might contain multiple GUIDs
+struct NodeGroup
+{
+ float X = 0.0f;
+ float Y = 0.0f;
+ float Z = 0.0f;
+ std::vector> FoundObjects;
+};
+
+struct PoolSession
+{
+ std::string ZoneName;
+ std::vector CurrentTemplate;
+ std::vector CapturedGroups;
+};
+
+static std::map PoolSessions;
+
+class pooltools_commandscript : public CommandScript
+{
+public:
+ pooltools_commandscript() : CommandScript("pooltools_commandscript") {}
+
+ ChatCommandTable GetCommands() const override
+ {
+ static ChatCommandTable poolToolsCommandTable =
+ {
+ { "start", HandlePoolStart, SEC_ADMINISTRATOR, Console::No },
+ { "def", HandlePoolDef, SEC_ADMINISTRATOR, Console::No },
+ { "add", HandlePoolAdd, SEC_ADMINISTRATOR, Console::No },
+ { "remove", HandlePoolRemove, SEC_ADMINISTRATOR, Console::No },
+ { "end", HandlePoolEnd, SEC_ADMINISTRATOR, Console::No },
+ { "clear", HandlePoolClear, SEC_ADMINISTRATOR, Console::No }
+ };
+
+ static ChatCommandTable commandTable =
+ {
+ { "pooltools", poolToolsCommandTable }
+ };
+ return commandTable;
+ }
+
+ static bool HandlePoolStart(ChatHandler* handler, std::string description)
+ {
+ ObjectGuid const playerGuid = handler->GetPlayer()->GetGUID();
+
+ if (PoolSessions.find(playerGuid) != PoolSessions.end())
+ {
+ handler->SendErrorMessage("Session already active. Use .pooltools clear first.");
+ return false;
+ }
+
+ PoolSession session;
+ session.ZoneName = description;
+
+ PoolSessions[playerGuid] = session;
+
+ handler->PSendSysMessage("|cff00ff00Pool Session Started.|r Description: {}", description);
+ return true;
+ }
+
+ static bool HandlePoolDef(ChatHandler* handler, Tail args)
+ {
+ ObjectGuid playerGuid = handler->GetPlayer()->GetGUID();
+ if (PoolSessions.find(playerGuid) == PoolSessions.end())
+ {
+ handler->SendErrorMessage("No active session.");
+ return false;
+ }
+
+ std::vector tokens = Acore::Tokenize(args, ' ', false);
+
+ if (tokens.empty() || tokens.size() % 2 != 0)
+ {
+ handler->SendErrorMessage("Invalid syntax. Usage: .pooltools def [ID] [Chance] [ID] [Chance]...");
+ return false;
+ }
+
+ std::vector newTemplate;
+ for (size_t i = 0; i < tokens.size(); i += 2)
+ {
+ uint32 const entry = Acore::StringTo(tokens[i]).value_or(0);
+ uint32 const chance = Acore::StringTo(tokens[i + 1]).value_or(0);
+
+ if (entry == 0)
+ continue;
+
+ newTemplate.push_back({ entry, chance });
+ }
+
+ PoolSessions[playerGuid].CurrentTemplate = newTemplate;
+ handler->PSendSysMessage("Template Defined ({} items).", newTemplate.size());
+ return true;
+ }
+
+ static bool HandlePoolAdd(ChatHandler* handler, Optional radiusArg)
+ {
+ ObjectGuid playerGuid = handler->GetPlayer()->GetGUID();
+ if (PoolSessions.find(playerGuid) == PoolSessions.end())
+ return false;
+
+ PoolSession& session = PoolSessions[playerGuid];
+ if (session.CurrentTemplate.empty())
+ {
+ handler->SendErrorMessage("Define a template first with .pooltools def");
+ return false;
+ }
+
+ Player* player = handler->GetPlayer();
+ float const radius = radiusArg.value_or(5.0f);
+
+ float searchX = player->GetPositionX();
+ float searchY = player->GetPositionY();
+ float searchZ = player->GetPositionZ();
+
+ GameObject* target = handler->GetNearbyGameObject();
+ if (radius <= 10.0f && target)
+ {
+ searchX = target->GetPositionX();
+ searchY = target->GetPositionY();
+ searchZ = target->GetPositionZ();
+ }
+
+ std::list nearbyGOs;
+ Acore::GameObjectInRangeCheck check(searchX, searchY, searchZ, radius);
+ Acore::GameObjectListSearcher searcher(player, nearbyGOs, check);
+ Cell::VisitObjects(player, searcher, radius);
+
+ int addedCount = 0;
+ int newGroupsCount = 0;
+
+ for (GameObject* go : nearbyGOs)
+ {
+ if (go->GetDistance(searchX, searchY, searchZ) > radius)
+ continue;
+
+ bool isTemplateMatch = false;
+ for (auto const& tpl : session.CurrentTemplate)
+ {
+ if (go->GetEntry() == tpl.Entry)
+ {
+ isTemplateMatch = true;
+ break;
+ }
+ }
+ if (!isTemplateMatch)
+ continue;
+
+ uint32 const spawnId = go->GetSpawnId();
+
+ bool alreadyCaptured = false;
+ for (auto const& group : session.CapturedGroups)
+ {
+ for (auto const& obj : group.FoundObjects)
+ {
+ if (obj.second == spawnId)
+ {
+ alreadyCaptured = true;
+ break;
+ }
+ }
+ if (alreadyCaptured)
+ break;
+ }
+ if (alreadyCaptured)
+ continue;
+
+ // Clustering
+ NodeGroup* existingGroup = nullptr;
+ for (auto& group : session.CapturedGroups)
+ {
+ if (go->GetDistance(group.X, group.Y, group.Z) < 0.1f)
+ {
+ existingGroup = &group;
+ break;
+ }
+ }
+
+ if (existingGroup)
+ {
+ existingGroup->FoundObjects.push_back({ go->GetEntry(), spawnId });
+ addedCount++;
+ }
+ else
+ {
+ NodeGroup newGroup;
+ newGroup.X = go->GetPositionX();
+ newGroup.Y = go->GetPositionY();
+ newGroup.Z = go->GetPositionZ();
+ newGroup.FoundObjects.push_back({ go->GetEntry(), spawnId });
+
+ session.CapturedGroups.push_back(newGroup);
+ newGroupsCount++;
+ addedCount++;
+ }
+ }
+
+ if (addedCount == 0)
+ {
+ handler->SendErrorMessage("No new matching objects found in {}y radius.", radius);
+ return false;
+ }
+
+ handler->PSendSysMessage("|cff00ff00Scan Complete.|r Added {} objects into {} new groups.", addedCount, newGroupsCount);
+
+ if (!session.CapturedGroups.empty())
+ {
+ NodeGroup& lastGroup = session.CapturedGroups.back();
+ for (auto& p : lastGroup.FoundObjects)
+ {
+ handler->PSendSysMessage(" - Entry {} (GUID: {})", p.first, p.second);
+ }
+ }
+ return true;
+ }
+
+ static bool HandlePoolRemove(ChatHandler* handler)
+ {
+ ObjectGuid const playerGuid = handler->GetPlayer()->GetGUID();
+ if (PoolSessions.find(playerGuid) == PoolSessions.end())
+ {
+ handler->SendErrorMessage("No active session.");
+ return false;
+ }
+
+ PoolSession& session = PoolSessions[playerGuid];
+
+ if (session.CapturedGroups.empty())
+ {
+ handler->SendErrorMessage("No groups captured.");
+ return false;
+ }
+
+ NodeGroup removed = session.CapturedGroups.back();
+ session.CapturedGroups.pop_back();
+
+ handler->PSendSysMessage("|cffff0000Undo Successful.|r Removed last group containing {} objects.", removed.FoundObjects.size());
+ return true;
+ }
+
+ static bool HandlePoolEnd(ChatHandler* handler)
+ {
+ ObjectGuid playerGuid = handler->GetPlayer()->GetGUID();
+ auto it = PoolSessions.find(playerGuid);
+ if (it == PoolSessions.end())
+ return false;
+
+ PoolSession& session = it->second; // Use reference from iterator
+
+ auto EscapeSQL = [](std::string_view input) -> std::string {
+ std::string safe;
+ safe.reserve(input.size());
+ for (char c : input)
+ {
+ if (c == '\'')
+ safe += "\\'";
+ else
+ safe += c;
+ }
+ return safe;
+ };
+
+ bool const complexPool = (session.CurrentTemplate.size() > 1);
+
+ // SQL Variables and Header
+ LOG_DEBUG("sql.dev", "-- Pool Dump: {}", session.ZoneName);
+ LOG_DEBUG("sql.dev", "SET @mother_pool := @mother_pool+1;");
+
+ if (complexPool)
+ LOG_DEBUG("sql.dev", "SET @pool_node := @pool_node+1;");
+
+ LOG_DEBUG("sql.dev", "SET @max_limit := {};", (session.CapturedGroups.size() + 3) / 4);
+
+ // DELETEs section
+ if (!session.CapturedGroups.empty())
+ {
+ LOG_DEBUG("sql.dev", "-- Cleanup specific object links");
+ LOG_DEBUG("sql.dev", "DELETE FROM `pool_gameobject` WHERE `guid` IN (");
+
+ std::vector guidList;
+ for (auto const& group : session.CapturedGroups)
+ for (auto const& obj : group.FoundObjects)
+ guidList.push_back(std::to_string(obj.second));
+
+ LOG_DEBUG("sql.dev", fmt::format("{}", fmt::join(guidList, ", ")));
+ LOG_DEBUG("sql.dev", ");\n");
+ }
+
+ LOG_DEBUG("sql.dev", "DELETE FROM `pool_template` WHERE `entry`=@mother_pool;");
+ LOG_DEBUG("sql.dev", "INSERT INTO `pool_template` (`entry`, `max_limit`, `description`) VALUES (@mother_pool, @max_limit, '{} - Mother Pool');", EscapeSQL(session.ZoneName));
+
+ int groupCounter = 0;
+
+ // We can buffer the simple bulk inserts here
+ std::vector bulkInserts;
+
+ for (auto const& group : session.CapturedGroups)
+ {
+ groupCounter++;
+
+ // Generate Description
+ std::set uniqueNames;
+ for (auto const& obj : group.FoundObjects)
+ {
+ GameObjectTemplate const* goInfo = sObjectMgr->GetGameObjectTemplate(obj.first);
+ uniqueNames.insert(goInfo ? goInfo->name : std::to_string(obj.first));
+ }
+
+ std::string groupDesc = fmt::format("{}", fmt::join(uniqueNames, " / "));
+ std::string safeGroupDesc = EscapeSQL(groupDesc);
+
+ // Simple pooling
+ if (!complexPool)
+ {
+ for (auto const& obj : group.FoundObjects)
+ {
+ float chance = 0.0f;
+ for (auto const& tpl : session.CurrentTemplate)
+ if (tpl.Entry == obj.first)
+ chance = (float)tpl.Chance;
+
+ bulkInserts.push_back(fmt::format("({}, @mother_pool, {}, '{} - {}')",
+ obj.second, chance, EscapeSQL(session.ZoneName), safeGroupDesc));
+ }
+ }
+ // Pool_pool integration
+ else
+ {
+ LOG_DEBUG("sql.dev", "-- Group {}", groupCounter);
+ LOG_DEBUG("sql.dev", "SET @pool_node := @pool_node + 1;");
+
+ // Create the Sub-Pool Node
+ LOG_DEBUG("sql.dev", "INSERT INTO `pool_template` (`entry`, `max_limit`, `description`) VALUES (@pool_node, 1, '{} - Node {}');", EscapeSQL(session.ZoneName), groupCounter);
+
+ // Link Node to Mother Pool
+ LOG_DEBUG("sql.dev", "INSERT INTO `pool_pool` (`pool_id`, `mother_pool`, `chance`, `description`) VALUES (@pool_node, @mother_pool, 0, '{} - {}');", EscapeSQL(session.ZoneName), safeGroupDesc);
+
+ // Link Objects to Sub-Pool Node
+ LOG_DEBUG("sql.dev", "INSERT INTO `pool_gameobject` (`guid`, `pool_entry`, `chance`, `description`) VALUES");
+
+ std::vector nodeInserts;
+ for (auto const& obj : group.FoundObjects)
+ {
+ GameObjectTemplate const* goInfo = sObjectMgr->GetGameObjectTemplate(obj.first);
+ std::string objName = goInfo ? goInfo->name : "Unknown";
+
+ float chance = 0.0f;
+ for (auto const& tpl : session.CurrentTemplate)
+ if (tpl.Entry == obj.first) chance = (float)tpl.Chance;
+
+ nodeInserts.push_back(fmt::format("({}, @pool_node, {}, '{} - {}')",
+ obj.second, chance, EscapeSQL(session.ZoneName), EscapeSQL(objName)));
+ }
+ LOG_DEBUG("sql.dev", "{};", fmt::join(nodeInserts, ",\n"));
+ }
+ }
+
+ if (!complexPool && !bulkInserts.empty())
+ {
+ LOG_DEBUG("sql.dev", "INSERT INTO `pool_gameobject` (`guid`, `pool_entry`, `chance`, `description`) VALUES");
+ LOG_DEBUG("sql.dev", "{};", fmt::join(bulkInserts, ",\n"));
+ }
+
+ handler->PSendSysMessage("Dumped {} groups.", groupCounter);
+
+ // Cleanup
+ PoolSessions.erase(it);
+ return true;
+ }
+
+ static bool HandlePoolClear(ChatHandler* handler)
+ {
+ PoolSessions.erase(handler->GetPlayer()->GetGUID());
+ handler->PSendSysMessage("Session cleared.");
+ return true;
+ }
+};
+
+void AddSC_pooltools_commandscript()
+{
+ new pooltools_commandscript();
+}
diff --git a/src/server/scripts/Commands/cs_script_loader.cpp b/src/server/scripts/Commands/cs_script_loader.cpp
index d07ab959f..7e667a66c 100644
--- a/src/server/scripts/Commands/cs_script_loader.cpp
+++ b/src/server/scripts/Commands/cs_script_loader.cpp
@@ -49,6 +49,7 @@ void AddSC_modify_commandscript();
void AddSC_npc_commandscript();
void AddSC_pet_commandscript();
void AddSC_player_commandscript();
+void AddSC_pooltools_commandscript();
void AddSC_quest_commandscript();
void AddSC_reload_commandscript();
void AddSC_reset_commandscript();
@@ -101,6 +102,7 @@ void AddCommandsScripts()
AddSC_npc_commandscript();
AddSC_pet_commandscript();
AddSC_player_commandscript();
+ AddSC_pooltools_commandscript();
AddSC_quest_commandscript();
AddSC_reload_commandscript();
AddSC_reset_commandscript();