боты, русификация, подключение ИИ

This commit is contained in:
2026-07-31 19:45:21 +04:00
parent 85adbe19da
commit b5c0a8a27a
118 changed files with 2928 additions and 482 deletions
+114
View File
@@ -0,0 +1,114 @@
# MoonWell Playerbots AI
This module sends a local `/say` or public-channel message to the LLM only when
a real player mentions the complete name of an online bot. A channel bot must
be a channel member; a `/say` bot must be within the server's normal listening
range. HTTP work runs outside the AzerothCore world thread.
Alice AI LLM Flash can classify direct natural-language requests into a small
allowlisted set of native playerbots commands. The server validates the command
again and executes it only against the addressed bot; existing playerbots
ownership and group security checks still apply.
The routing prompt covers party movement and combat, LFG, combat/non-combat
strategies, spells, RTI/RTSC, focus healing, loot lists, inventory operations,
quests, pets, talents, glyphs, maintenance, and information commands documented
in the upstream Playerbot Commands wiki. Administrative, console, account,
debug/cheat, and destructive `destroy`/`drop`/mail commands are intentionally
not executable through the LLM.
Common Russian movement/combat phrases use a deterministic fast path. Other
requests first pass through a command-only classifier; ordinary conversation is
then sent separately to the roleplay prompt. While AI routing is enabled, the
legacy public-channel parser is bypassed and `setup-modules.sh` also forces
`AiPlayerbot.RandomBotTalk = 0`. RandomBotTalk consumes incoming chat packets
inside each bot, so bypassing only the parser does not prevent its delayed
stock replies. Whisper, party, raid, and guild command hooks remain unchanged.
Recognized commands produce a gender-aware local-chat acknowledgement
(`Понял команду.` or `Поняла команду.`) before they are handed to the native
playerbots command handler. Bot and player gender are also included in the LLM
context for pronoun, past-tense verb, and adjective agreement. Russian requests
to show bags or inventory use the native `inv` command; `open items` is reserved
for actually opening container items.
Trade and quest requests have deterministic link-preserving routes before the
LLM classifier:
- `дай/передай [item]` -> `t [item]`; `начни обмен` -> `t`
- `покажи задания` -> `quests all`; completed/incomplete filters are supported
- `покажи прогресс [quest]` -> `q [quest]`
- `прими [quest]` / `прими все задания` -> `accept [quest]` / `accept *`
- `поделись [quest]` -> `share [quest]`
- `выбери [item] в награду` -> `r [item]`
- `сдай готовое задание` -> `talk`
- `откажись от [quest]` -> `drop [quest]` only with an explicit quest link
These routes preserve the original WoW hyperlink byte-for-byte. The local
playerbots trade action continues processing after it creates the trade, so one
`t [item]` command both initiates the exchange and places the requested item in
the trade window.
The bundled `playerbots-ai` bridge uses the Yandex AI Studio OpenAI-compatible
Chat Completions API and defaults to Alice AI LLM Flash.
## Configuration
Set these values in the repository `.env`:
```dotenv
ACORE_PLAYERBOTS_AI_ENABLED=1
ACORE_PLAYERBOTS_AI_COMMANDS_ENABLED=1
YANDEX_AI_API_KEY=<service-account API key>
YANDEX_AI_FOLDER_ID=<Yandex Cloud folder ID>
```
Until the subscription system exists, public access is controlled by one
explicit switch:
```dotenv
ACORE_PLAYERBOTS_AI_AVAILABLE_FOR_ALL=1
```
`1` allows every player to reach the bridge. `0` denies LLM access to everyone
without spending tokens. When subscriptions are implemented, their entitlement
check will be added to the disabled branch instead of using a temporary manual
account list.
The command classifier has a persistent SQLite cache in the
`playerbots-ai-cache` Docker volume. The key excludes the addressed bot name,
so repeated phrases such as “покажи инвентарь” are shared across players and
bots. Both commands and “ordinary chat, not a command” decisions are cached.
The cache does not store roleplay responses or conversation history.
Obvious social phrases such as greetings, “как дела?” and “расскажи о себе”
skip the command classifier entirely: they still receive an Alice roleplay
reply, but do not pay the roughly 800-token command-catalog prompt first.
```dotenv
PLAYERBOTS_AI_CLASSIFIER_CACHE_ENABLED=1
PLAYERBOTS_AI_CLASSIFIER_CACHE_TTL_SECONDS=604800
PLAYERBOTS_AI_CLASSIFIER_CACHE_MAX_ENTRIES=10000
```
Changing the classifier prompt automatically creates a new cache namespace.
Old rows expire normally, and the oldest rows are removed at the size limit.
The default model URI is:
```text
gpt://<YANDEX_AI_FOLDER_ID>/aliceai-llm-flash
```
Run `./start-server.sh` or `scripts/prod-deploy.sh`. The deployment wrapper
builds and starts the bridge before `ac-worldserver`.
Never commit a populated API key.
Full Yandex response JSON is logged by default by the bridge:
```dotenv
PLAYERBOTS_AI_LOG_API_RESPONSES=1
```
Use `docker compose logs -f playerbots-ai` to follow classifier and chat
responses. Authentication headers and the API key are never logged.
@@ -0,0 +1,29 @@
#
# MoonWell Playerbots AI
#
# Enables LLM replies in public channels.
PlayerbotsAI.Enabled = 0
# Enables natural-language commands through an allowlisted playerbots command set.
PlayerbotsAI.CommandsEnabled = 1
# Temporary public-access switch. A future subscription check will be inserted
# when this is disabled; until that system exists, 0 denies access to everyone.
PlayerbotsAI.AvailableForAll = 1
# Internal HTTP bridge. The Yandex API key is kept in the bridge container.
PlayerbotsAI.BridgeHost = playerbots-ai
PlayerbotsAI.BridgePort = 8080
PlayerbotsAI.BridgePath = /v1/chat
# Per-player request cooldown and bounded worker queue.
PlayerbotsAI.PlayerCooldownSeconds = 8
PlayerbotsAI.MaxQueuedRequests = 32
# Maximum UTF-8 characters sent back to an in-game channel.
PlayerbotsAI.MaxReplyCharacters = 240
# Extra request/response logging. Prompts and API keys are never logged.
PlayerbotsAI.Debug = 0
п
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# This module uses AzerothCore's standard source and loader discovery.
@@ -0,0 +1,808 @@
#include "Channel.h"
#include "ChannelMgr.h"
#include "Config.h"
#include "Language.h"
#include "Log.h"
#include "ObjectAccessor.h"
#include "Player.h"
#include "PlayerbotAI.h"
#include "PlayerbotMgr.h"
#include "RandomPlayerbotMgr.h"
#include "ScriptMgr.h"
#include "World.h"
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/read.hpp>
#include <boost/asio/read_until.hpp>
#include <boost/asio/streambuf.hpp>
#include <boost/asio/write.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <iomanip>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace
{
struct AiRequest
{
ObjectGuid botGuid;
ObjectGuid playerGuid;
TeamId teamId;
std::string channelName;
std::string host;
std::string port;
std::string path;
std::string body;
};
struct AiResponse
{
ObjectGuid botGuid;
ObjectGuid playerGuid;
TeamId teamId;
std::string channelName;
std::string command;
std::string text;
};
struct BridgeResponse
{
std::string command;
std::string text;
};
std::string JsonEscape(std::string const& value)
{
std::ostringstream output;
for (unsigned char character : value)
{
switch (character)
{
case '"': output << "\\\""; break;
case '\\': output << "\\\\"; break;
case '\b': output << "\\b"; break;
case '\f': output << "\\f"; break;
case '\n': output << "\\n"; break;
case '\r': output << "\\r"; break;
case '\t': output << "\\t"; break;
default:
if (character < 0x20)
{
output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
<< static_cast<uint32>(character) << std::dec;
}
else
{
output << static_cast<char>(character);
}
break;
}
}
return output.str();
}
std::vector<uint32> DecodeAndCaseFold(std::string const& text)
{
std::vector<uint32> result;
result.reserve(text.size());
for (std::size_t index = 0; index < text.size();)
{
unsigned char first = static_cast<unsigned char>(text[index]);
uint32 codepoint = 0xFFFD;
std::size_t length = 1;
if (first < 0x80)
{
codepoint = first;
}
else if ((first & 0xE0) == 0xC0 && index + 1 < text.size())
{
codepoint = ((first & 0x1F) << 6)
| (static_cast<unsigned char>(text[index + 1]) & 0x3F);
length = 2;
}
else if ((first & 0xF0) == 0xE0 && index + 2 < text.size())
{
codepoint = ((first & 0x0F) << 12)
| ((static_cast<unsigned char>(text[index + 1]) & 0x3F) << 6)
| (static_cast<unsigned char>(text[index + 2]) & 0x3F);
length = 3;
}
else if ((first & 0xF8) == 0xF0 && index + 3 < text.size())
{
codepoint = ((first & 0x07) << 18)
| ((static_cast<unsigned char>(text[index + 1]) & 0x3F) << 12)
| ((static_cast<unsigned char>(text[index + 2]) & 0x3F) << 6)
| (static_cast<unsigned char>(text[index + 3]) & 0x3F);
length = 4;
}
if (codepoint >= 'A' && codepoint <= 'Z')
codepoint += 'a' - 'A';
else if (codepoint >= 0x0410 && codepoint <= 0x042F)
codepoint += 0x20;
else if (codepoint == 0x0401)
codepoint = 0x0451;
result.push_back(codepoint);
index += length;
}
return result;
}
bool IsWordCodepoint(uint32 codepoint)
{
return (codepoint >= '0' && codepoint <= '9')
|| (codepoint >= 'a' && codepoint <= 'z')
|| (codepoint >= 0x0430 && codepoint <= 0x044F)
|| codepoint == 0x0451;
}
std::optional<std::size_t> FindCompleteName(
std::vector<uint32> const& message,
std::vector<uint32> const& name)
{
if (name.empty() || name.size() > message.size())
return std::nullopt;
for (std::size_t position = 0; position + name.size() <= message.size(); ++position)
{
if (!std::equal(name.begin(), name.end(), message.begin() + position))
continue;
bool leftBoundary = position == 0 || !IsWordCodepoint(message[position - 1]);
std::size_t end = position + name.size();
bool rightBoundary = end == message.size() || !IsWordCodepoint(message[end]);
if (leftBoundary && rightBoundary)
return position;
}
return std::nullopt;
}
std::string NormalizeReply(std::string text, uint32 maxCharacters)
{
std::replace(text.begin(), text.end(), '\r', ' ');
std::replace(text.begin(), text.end(), '\n', ' ');
std::string compact;
compact.reserve(text.size());
bool previousSpace = false;
for (unsigned char character : text)
{
bool isSpace = character == ' ' || character == '\t';
if (isSpace && previousSpace)
continue;
compact.push_back(isSpace ? ' ' : static_cast<char>(character));
previousSpace = isSpace;
}
std::size_t first = compact.find_first_not_of(' ');
if (first == std::string::npos)
return {};
compact.erase(0, first);
std::size_t last = compact.find_last_not_of(' ');
compact.erase(last + 1);
if (!maxCharacters)
return compact;
std::size_t byteIndex = 0;
uint32 characters = 0;
while (byteIndex < compact.size() && characters < maxCharacters)
{
unsigned char firstByte = static_cast<unsigned char>(compact[byteIndex]);
std::size_t length = firstByte < 0x80 ? 1
: (firstByte & 0xE0) == 0xC0 ? 2
: (firstByte & 0xF0) == 0xE0 ? 3
: (firstByte & 0xF8) == 0xF0 ? 4
: 1;
byteIndex = std::min(compact.size(), byteIndex + length);
++characters;
}
if (byteIndex < compact.size())
{
compact.resize(byteIndex);
compact += "";
}
return compact;
}
BridgeResponse ParseBridgeResponse(std::string response)
{
static std::string const commandPrefix = "PB-COMMAND:";
if (response.rfind(commandPrefix, 0) != 0)
return {"", std::move(response)};
std::size_t newline = response.find('\n');
if (newline == std::string::npos)
return {"", ""};
return {
response.substr(commandPrefix.size(), newline - commandPrefix.size()),
response.substr(newline + 1)
};
}
bool IsAllowedPlayerbotCommand(std::string const& command)
{
if (command.empty() || command.size() > 300 || command.find('\\') != std::string::npos)
return false;
if (std::any_of(command.begin(), command.end(), [](unsigned char character)
{
return character < 0x20;
}))
{
return false;
}
static std::set<std::string> const allowedCommands = {
"accept",
"accept *",
"add all loot",
"attack",
"attack rti target",
"autogear",
"buff",
"disperse disable",
"flee",
"focus heal ?",
"focus heal clear",
"focus heal none",
"focus heal unset",
"follow",
"give leader",
"glyphs",
"grind",
"help",
"home",
"inv",
"invite",
"items",
"leave",
"lfg",
"ll all",
"ll gray",
"ll normal",
"ll quest",
"ll skill",
"los",
"maintenance",
"open items",
"outfit ?",
"pet aggressive",
"pet attack",
"pet defensive",
"pet follow",
"pet passive",
"pet stance",
"pet stay",
"quests",
"quests all",
"quests completed",
"quests incompleted",
"ready",
"release",
"reset",
"reset botAI",
"revive",
"roll",
"rti",
"rtsc",
"rtsc cancel",
"rtsc go save",
"rtsc toggle",
"runaway",
"spells",
"ss reset",
"stay",
"stats",
"summon",
"t",
"talents",
"talents spec list",
"talk",
"tame",
"tame family",
"tank attack",
"trainer",
"trainer learn",
"who"
};
if (allowedCommands.find(command) != allowedCommands.end())
return true;
static std::vector<std::string> const allowedPrefixes = {
"accept ",
"b ",
"bank ",
"cast ",
"co ",
"disperse set ",
"e ",
"focus heal ",
"gb ",
"glyph equip ",
"lfg ",
"ll ",
"nc ",
"outfit ",
"pet ",
"q ",
"r ",
"roll ",
"rti ",
"rtsc ",
"s ",
"ss ",
"share ",
"talents apply ",
"talents spec ",
"tame ",
"t ",
"drop ",
"u ",
"ue ",
"who "
};
return std::any_of(
allowedPrefixes.begin(),
allowedPrefixes.end(),
[&command](std::string const& prefix)
{
return command.size() > prefix.size() && command.rfind(prefix, 0) == 0;
});
}
std::string ReadHttpBody(
std::string const& host,
std::string const& port,
std::string const& path,
std::string const& body)
{
using boost::asio::ip::tcp;
boost::asio::io_context ioContext;
tcp::resolver resolver(ioContext);
tcp::socket socket(ioContext);
boost::asio::connect(socket, resolver.resolve(host, port));
std::ostringstream request;
request << "POST " << path << " HTTP/1.1\r\n"
<< "Host: " << host << ':' << port << "\r\n"
<< "User-Agent: MoonWell-playerbots-ai/1.0\r\n"
<< "Content-Type: application/json; charset=utf-8\r\n"
<< "Accept: text/plain\r\n"
<< "Connection: close\r\n"
<< "Content-Length: " << body.size() << "\r\n\r\n"
<< body;
boost::asio::write(socket, boost::asio::buffer(request.str()));
boost::asio::streambuf response;
boost::asio::read_until(socket, response, "\r\n\r\n");
std::istream responseStream(&response);
std::string httpVersion;
unsigned int statusCode = 0;
std::string statusMessage;
responseStream >> httpVersion >> statusCode;
std::getline(responseStream, statusMessage);
if (!responseStream || httpVersion.rfind("HTTP/", 0) != 0)
throw std::runtime_error("invalid HTTP response");
std::string header;
while (std::getline(responseStream, header) && header != "\r")
{
}
std::ostringstream responseBody;
if (response.size())
responseBody << &response;
boost::system::error_code error;
while (boost::asio::read(socket, response, boost::asio::transfer_at_least(1), error))
responseBody << &response;
if (error != boost::asio::error::eof)
throw boost::system::system_error(error);
if (response.size())
responseBody << &response;
if (statusCode < 200 || statusCode >= 300)
{
throw std::runtime_error(
"bridge returned HTTP " + std::to_string(statusCode) + ": " + responseBody.str());
}
return responseBody.str();
}
class PlayerbotsAiBridge
{
public:
static PlayerbotsAiBridge& Instance()
{
static PlayerbotsAiBridge instance;
return instance;
}
void Start()
{
bool expected = false;
if (!_running.compare_exchange_strong(expected, true))
return;
_stopping = false;
_worker = std::thread([this] { Run(); });
LOG_INFO("server.loading", "[PlayerbotsAI] Async bridge worker started");
}
void Stop()
{
if (!_running.exchange(false))
return;
{
std::lock_guard<std::mutex> lock(_requestMutex);
_stopping = true;
}
_requestReady.notify_all();
if (_worker.joinable())
_worker.join();
std::lock_guard<std::mutex> requestLock(_requestMutex);
_requests.clear();
LOG_INFO("server.loading", "[PlayerbotsAI] Async bridge worker stopped");
}
bool Submit(AiRequest request, uint32 maxQueuedRequests)
{
if (!_running)
return false;
std::lock_guard<std::mutex> lock(_requestMutex);
if (_stopping || _requests.size() >= maxQueuedRequests)
return false;
_requests.push_back(std::move(request));
_requestReady.notify_one();
return true;
}
std::deque<AiResponse> TakeResponses()
{
std::lock_guard<std::mutex> lock(_responseMutex);
std::deque<AiResponse> responses;
responses.swap(_responses);
return responses;
}
private:
PlayerbotsAiBridge() = default;
~PlayerbotsAiBridge()
{
Stop();
}
void Run()
{
while (true)
{
AiRequest request;
{
std::unique_lock<std::mutex> lock(_requestMutex);
_requestReady.wait(lock, [this] { return _stopping || !_requests.empty(); });
if (_stopping && _requests.empty())
break;
request = std::move(_requests.front());
_requests.pop_front();
}
try
{
BridgeResponse response = ParseBridgeResponse(ReadHttpBody(
request.host, request.port, request.path, request.body));
std::lock_guard<std::mutex> lock(_responseMutex);
_responses.push_back({
request.botGuid,
request.playerGuid,
request.teamId,
std::move(request.channelName),
std::move(response.command),
std::move(response.text)
});
}
catch (std::exception const& error)
{
LOG_ERROR("server.loading", "[PlayerbotsAI] Bridge request failed: {}", error.what());
}
}
}
std::atomic<bool> _running{false};
bool _stopping{false};
std::thread _worker;
std::mutex _requestMutex;
std::condition_variable _requestReady;
std::deque<AiRequest> _requests;
std::mutex _responseMutex;
std::deque<AiResponse> _responses;
};
bool IsEnabled()
{
return sConfigMgr->GetOption<bool>("PlayerbotsAI.Enabled", false);
}
bool IsAvailableForAll()
{
return sConfigMgr->GetOption<bool>("PlayerbotsAI.AvailableForAll", true);
}
AiRequest BuildRequest(
Player* bot,
Player* player,
std::string channelName,
std::string const& message)
{
std::ostringstream body;
body << '{'
<< "\"bot_guid\":\"" << bot->GetGUID().GetCounter() << "\","
<< "\"player_guid\":\"" << player->GetGUID().GetCounter() << "\","
<< "\"bot_name\":\"" << JsonEscape(bot->GetName()) << "\","
<< "\"player_name\":\"" << JsonEscape(player->GetName()) << "\","
<< "\"message\":\"" << JsonEscape(message) << "\","
<< "\"bot_gender\":" << static_cast<uint32>(bot->getGender()) << ','
<< "\"player_gender\":" << static_cast<uint32>(player->getGender()) << ','
<< "\"bot_class\":" << static_cast<uint32>(bot->getClass()) << ','
<< "\"bot_race\":" << static_cast<uint32>(bot->getRace()) << ','
<< "\"bot_level\":" << static_cast<uint32>(bot->GetLevel()) << ','
<< "\"zone_id\":" << bot->GetZoneId()
<< '}';
return {
bot->GetGUID(),
player->GetGUID(),
bot->GetTeamId(),
std::move(channelName),
sConfigMgr->GetOption<std::string>("PlayerbotsAI.BridgeHost", "playerbots-ai"),
std::to_string(sConfigMgr->GetOption<uint32>("PlayerbotsAI.BridgePort", 8080)),
sConfigMgr->GetOption<std::string>("PlayerbotsAI.BridgePath", "/v1/chat"),
body.str()
};
}
class PlayerbotsAiPlayerScript : public PlayerScript
{
public:
PlayerbotsAiPlayerScript()
: PlayerScript(
"PlayerbotsAiPlayerScript",
{
PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE,
PLAYERHOOK_CAN_PLAYER_USE_CHANNEL_CHAT
})
{
}
void OnPlayerBeforeSendChatMessage(
Player* player,
uint32& type,
uint32& /*language*/,
std::string& message) override
{
if (type == CHAT_MSG_SAY)
HandleAddressedMessage(player, message, nullptr);
}
bool OnPlayerCanUseChat(
Player* player,
uint32 type,
uint32 /*language*/,
std::string& message,
Channel* channel) override
{
if (type == CHAT_MSG_CHANNEL)
HandleAddressedMessage(player, message, channel);
return true;
}
private:
void HandleAddressedMessage(Player* player, std::string const& message, Channel* channel)
{
if (!IsEnabled() || !player || message.empty())
return;
if (PlayerbotsMgr::instance().GetPlayerbotAI(player))
return;
std::vector<uint32> foldedMessage = DecodeAndCaseFold(message);
Player* selectedBot = nullptr;
std::size_t selectedPosition = foldedMessage.size();
PlayerBotMap bots = sRandomPlayerbotMgr.GetAllBots();
if (PlayerbotMgr* playerbotMgr = PlayerbotsMgr::instance().GetPlayerbotMgr(player))
{
for (auto iterator = playerbotMgr->GetPlayerBotsBegin();
iterator != playerbotMgr->GetPlayerBotsEnd();
++iterator)
{
bots.emplace(iterator->first, iterator->second);
}
}
for (auto const& [guid, bot] : bots)
{
if (!bot || !bot->IsInWorld())
continue;
if (channel)
{
if (!channel->HasMember(guid))
continue;
}
else if (!bot->IsWithinDistInMap(
player, sWorld->getFloatConfig(CONFIG_LISTEN_RANGE_SAY)))
{
continue;
}
std::optional<std::size_t> position =
FindCompleteName(foldedMessage, DecodeAndCaseFold(bot->GetName()));
if (position && *position < selectedPosition)
{
selectedBot = bot;
selectedPosition = *position;
}
}
if (!selectedBot)
return;
if (!IsAvailableForAll())
{
std::string const deniedReply = "Разговоры со мной через ИИ сейчас недоступны.";
if (channel)
channel->Say(selectedBot->GetGUID(), deniedReply, LANG_UNIVERSAL);
else
selectedBot->Say(deniedReply, LANG_UNIVERSAL);
return;
}
auto now = std::chrono::steady_clock::now();
uint32 cooldownSeconds =
sConfigMgr->GetOption<uint32>("PlayerbotsAI.PlayerCooldownSeconds", 8);
auto cooldown = std::chrono::seconds(cooldownSeconds);
auto found = _lastRequest.find(player->GetGUID());
if (found != _lastRequest.end() && now - found->second < cooldown)
return;
uint32 maxQueuedRequests =
std::max<uint32>(1, sConfigMgr->GetOption<uint32>("PlayerbotsAI.MaxQueuedRequests", 32));
if (!PlayerbotsAiBridge::Instance().Submit(
BuildRequest(
selectedBot,
player,
channel ? channel->GetName() : std::string(),
message),
maxQueuedRequests))
{
LOG_WARN("server.loading", "[PlayerbotsAI] Request queue is unavailable or full");
return;
}
_lastRequest[player->GetGUID()] = now;
if (sConfigMgr->GetOption<bool>("PlayerbotsAI.Debug", false))
{
LOG_INFO(
"server.loading",
"[PlayerbotsAI] {} addressed bot {} in {}",
player->GetName(),
selectedBot->GetName(),
channel ? channel->GetName() : "/say");
}
}
std::map<ObjectGuid, std::chrono::steady_clock::time_point> _lastRequest;
};
class PlayerbotsAiWorldScript : public WorldScript
{
public:
PlayerbotsAiWorldScript()
: WorldScript(
"PlayerbotsAiWorldScript",
{WORLDHOOK_ON_STARTUP, WORLDHOOK_ON_UPDATE, WORLDHOOK_ON_SHUTDOWN})
{
}
void OnStartup() override
{
if (IsEnabled())
PlayerbotsAiBridge::Instance().Start();
}
void OnUpdate(uint32 /*diff*/) override
{
if (!IsEnabled())
return;
uint32 maxCharacters =
sConfigMgr->GetOption<uint32>("PlayerbotsAI.MaxReplyCharacters", 240);
for (AiResponse& response : PlayerbotsAiBridge::Instance().TakeResponses())
{
Player* bot = ObjectAccessor::FindPlayer(response.botGuid);
if (!bot || !bot->IsInWorld() || !PlayerbotsMgr::instance().GetPlayerbotAI(bot))
continue;
Player* player = ObjectAccessor::FindPlayer(response.playerGuid);
if (!player || !player->IsInWorld())
continue;
Channel* channel = nullptr;
if (response.channelName.empty())
{
if (!bot->IsWithinDistInMap(
player, sWorld->getFloatConfig(CONFIG_LISTEN_RANGE_SAY)))
{
continue;
}
}
else
{
ChannelMgr* channelManager = ChannelMgr::forTeam(response.teamId);
channel = channelManager
? channelManager->GetChannel(response.channelName, bot, false)
: nullptr;
if (!channel || !channel->HasMember(bot->GetGUID()))
continue;
}
if (sConfigMgr->GetOption<bool>("PlayerbotsAI.CommandsEnabled", true)
&& IsAllowedPlayerbotCommand(response.command))
{
PlayerbotAI* botAi = PlayerbotsMgr::instance().GetPlayerbotAI(bot);
botAi->HandleCommand(CHAT_MSG_WHISPER, response.command, player);
}
std::string reply = NormalizeReply(std::move(response.text), maxCharacters);
if (!reply.empty())
{
if (channel)
channel->Say(bot->GetGUID(), reply, LANG_UNIVERSAL);
else
bot->Say(reply, LANG_UNIVERSAL);
}
}
}
void OnShutdown() override
{
PlayerbotsAiBridge::Instance().Stop();
}
};
}
void AddSC_mod_playerbots_ai()
{
new PlayerbotsAiPlayerScript();
new PlayerbotsAiWorldScript();
}
@@ -0,0 +1,6 @@
void AddSC_mod_playerbots_ai();
void Addmod_playerbots_aiScripts()
{
AddSC_mod_playerbots_ai();
}