боты, русификация, подключение ИИ
This commit is contained in:
@@ -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
|
||||
п
|
||||
@@ -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();
|
||||
}
|
||||
Reference in New Issue
Block a user