боты русифицированы

This commit is contained in:
2026-07-30 19:06:57 +04:00
parent 6f075133f7
commit 85adbe19da
26 changed files with 11211 additions and 28 deletions
+11
View File
@@ -393,6 +393,14 @@ ACORE_LEARNSPELLS_RIDING_COLD_WEATHER_FLYING=1
# 1 = включает playerbots.conf, если модуль поддерживается сборкой.
ACORE_PLAYERBOTS_ENABLED=1
# 1 = при ./start-server.sh и scripts/prod-deploy.sh автоматически
# русифицировать имена RNDbot, названия их гильдий и статические сообщения.
# Ручной повторный запуск: ./localize-playerbots-ru.sh --apply
ACORE_PLAYERBOTS_RUSSIAN_NAMES=1
# 1 = разрешить ботам отвечать и участвовать в статическом чате.
ACORE_PLAYERBOTS_RANDOM_BOT_TALK=1
# Автологин random bots.
# Для сервера под соло/кооп лучше оставить 0, если вам нужны только боты в группу по команде.
ACORE_PLAYERBOTS_RANDOM_BOT_AUTOLOGIN=0
@@ -412,6 +420,9 @@ ACORE_PLAYERBOTS_RANDOM_BOT_ACCOUNT_COUNT=0
# После этого ОБЯЗАТЕЛЬНО верните 0.
ACORE_PLAYERBOTS_DELETE_RANDOM_BOT_ACCOUNTS=0
# Разовая очистка гильдий randombot перед удалением их аккаунтов.
ACORE_PLAYERBOTS_DELETE_RANDOM_BOT_GUILDS=0
# Сколько ботов игрок может одновременно добавить в группу.
ACORE_PLAYERBOTS_MAX_ADDED_BOTS=9
File diff suppressed because it is too large Load Diff
+22 -1
View File
@@ -15,6 +15,7 @@ fi
IMPORT_MYTHICPLUS_SQL="${ACORE_IMPORT_MYTHICPLUS_SQL:-1}"
IMPORT_STORE_SQL="${ACORE_IMPORT_STORE_SQL:-1}"
IMPORT_ENCOUNTER_JOURNAL_SQL="${ACORE_IMPORT_ENCOUNTER_JOURNAL_SQL:-1}"
PLAYERBOTS_RUSSIAN_NAMES="${ACORE_PLAYERBOTS_RUSSIAN_NAMES:-1}"
usage() {
cat <<EOF
@@ -22,7 +23,8 @@ Usage: $(basename "$0")
Imports required custom SQL that lives outside the normal AzerothCore module paths.
Currently this bootstraps the MythicPlus and Store Lua packages, plus module strings
that are not covered by the normal module SQL import path in this deployment.
and playerbot localization data not covered by the normal module SQL import path
in this deployment.
EOF
}
@@ -257,6 +259,23 @@ import_aoe_loot_sql() {
import_sql_file "acore_world" "$aoe_loot_sql_file"
}
import_playerbots_ru_sql() {
local names_sql_file="$ROOT_DIR/modules/mod-playerbots/data/sql/characters/updates/2026_07_30_00_playerbots_russian_names.sql"
if [[ "$PLAYERBOTS_RUSSIAN_NAMES" == "0" ]]; then
log "Russian playerbot localization disabled, skipping name pools"
return 0
fi
if table_exists "acore_characters" "playerbots_names_ru" && \
table_exists "acore_characters" "playerbots_guild_names_ru"; then
log "Russian playerbot name pools already exist, skipping"
return 0
fi
import_sql_file "acore_characters" "$names_sql_file"
}
configure_server_motd() {
log "configuring MoonWell login message"
mysql_exec "acore_auth" "
@@ -326,6 +345,7 @@ fi
require_integer_flag "ACORE_IMPORT_MYTHICPLUS_SQL" "$IMPORT_MYTHICPLUS_SQL"
require_integer_flag "ACORE_IMPORT_STORE_SQL" "$IMPORT_STORE_SQL"
require_integer_flag "ACORE_IMPORT_ENCOUNTER_JOURNAL_SQL" "$IMPORT_ENCOUNTER_JOURNAL_SQL"
require_integer_flag "ACORE_PLAYERBOTS_RUSSIAN_NAMES" "$PLAYERBOTS_RUSSIAN_NAMES"
cd "$ROOT_DIR"
@@ -336,6 +356,7 @@ fi
import_mythicplus_sql
import_aoe_loot_sql
import_playerbots_ru_sql
import_encounter_journal_sql
import_store_sql
configure_server_motd
+213
View File
@@ -0,0 +1,213 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
cd "$SCRIPT_DIR"
MODE="${1:---dry-run}"
CHARACTER_NAMES_SQL="modules/mod-playerbots/data/sql/characters/updates/2026_07_30_00_playerbots_russian_names.sql"
BOT_TEXTS_SQL="modules/mod-playerbots/data/sql/playerbots/updates/2026_07_30_00_ai_playerbot_russian_texts.sql"
case "$MODE" in
--dry-run | --apply) ;;
*)
echo "Usage: $0 [--dry-run|--apply]" >&2
exit 2
;;
esac
if ! docker compose ps --status running --services | grep -qx "ac-database"; then
echo "ac-database must be running." >&2
exit 1
fi
mysql_query() {
local database="$1"
local sql="$2"
docker compose exec -T ac-database bash -lc \
"mysql --default-character-set=utf8mb4 -N -s -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$database\"" <<<"$sql"
}
import_sql() {
local database="$1"
local file_path="$2"
docker compose exec -T ac-database bash -lc \
"mysql --default-character-set=utf8mb4 -uroot -p\"\$MYSQL_ROOT_PASSWORD\" \"$database\"" <"$file_path"
}
read -r bot_count guild_count < <(
mysql_query acore_characters "
SELECT
COUNT(DISTINCT CASE
WHEN REGEXP_LIKE(c.name, '^[[:ascii:]]+$') THEN c.guid
END),
COUNT(DISTINCT CASE
WHEN REGEXP_LIKE(g.name, '^[[:ascii:]]+$') THEN g.guildid
END)
FROM characters c
JOIN acore_playerbots.playerbots_account_type pat
ON pat.account_id = c.account AND pat.account_type = 1
LEFT JOIN guild g ON g.leaderguid = c.guid;
"
)
echo "RNDbot characters to rename: $bot_count"
echo "RNDbot-led guilds to rename: $guild_count"
if [[ "$MODE" == "--dry-run" ]]; then
echo "Dry run only. Stop ac-worldserver and run '$0 --apply' to apply."
exit 0
fi
if docker compose ps --status running --services | grep -qx "ac-worldserver"; then
echo "Refusing to rename live characters while ac-worldserver is running." >&2
echo "Stop it with: docker compose stop ac-worldserver" >&2
exit 1
fi
import_sql acore_characters "$CHARACTER_NAMES_SQL"
import_sql acore_playerbots "$BOT_TEXTS_SQL"
mysql_query acore_characters "
SET NAMES utf8mb4;
CREATE TABLE IF NOT EXISTS moonwell_playerbot_name_backup (
guid INT UNSIGNED NOT NULL,
original_name VARCHAR(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
localized_name VARCHAR(12) CHARACTER SET utf8mb4 COLLATE utf8mb4_bin NOT NULL,
changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (guid),
UNIQUE KEY uq_moonwell_playerbot_localized_name (localized_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
CREATE TABLE IF NOT EXISTS moonwell_playerbot_guild_name_backup (
guildid INT UNSIGNED NOT NULL,
original_name VARCHAR(24) NOT NULL,
localized_name VARCHAR(24) NOT NULL,
changed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (guildid),
UNIQUE KEY uq_moonwell_playerbot_localized_guild_name (localized_name)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
DROP TEMPORARY TABLE IF EXISTS tmp_moonwell_available_names;
CREATE TEMPORARY TABLE tmp_moonwell_available_names AS
SELECT
n.gender,
n.name,
ROW_NUMBER() OVER (PARTITION BY n.gender ORDER BY n.name_id) AS row_number_in_category
FROM playerbots_names_ru n
LEFT JOIN characters used
ON used.name = CONVERT(n.name USING utf8mb4) COLLATE utf8mb4_bin
LEFT JOIN moonwell_playerbot_name_backup reserved
ON reserved.localized_name = CONVERT(n.name USING utf8mb4) COLLATE utf8mb4_bin
WHERE used.guid IS NULL
AND reserved.guid IS NULL;
DROP TEMPORARY TABLE IF EXISTS tmp_moonwell_bot_targets;
CREATE TEMPORARY TABLE tmp_moonwell_bot_targets AS
SELECT
c.guid,
c.name AS original_name,
CASE
WHEN c.race IN (1, 5) THEN c.gender
WHEN c.race = 7 THEN 2 + c.gender
WHEN c.race = 3 THEN 4 + c.gender
WHEN c.race = 4 THEN 6 + c.gender
WHEN c.race = 11 THEN 8 + c.gender
WHEN c.race = 2 THEN 10 + c.gender
WHEN c.race = 8 THEN 12 + c.gender
WHEN c.race = 6 THEN 14 + c.gender
WHEN c.race = 10 THEN 16 + c.gender
END AS name_category,
ROW_NUMBER() OVER (
PARTITION BY CASE
WHEN c.race IN (1, 5) THEN c.gender
WHEN c.race = 7 THEN 2 + c.gender
WHEN c.race = 3 THEN 4 + c.gender
WHEN c.race = 4 THEN 6 + c.gender
WHEN c.race = 11 THEN 8 + c.gender
WHEN c.race = 2 THEN 10 + c.gender
WHEN c.race = 8 THEN 12 + c.gender
WHEN c.race = 6 THEN 14 + c.gender
WHEN c.race = 10 THEN 16 + c.gender
END
ORDER BY c.guid
) AS row_number_in_category
FROM characters c
JOIN acore_playerbots.playerbots_account_type pat
ON pat.account_id = c.account AND pat.account_type = 1
WHERE REGEXP_LIKE(c.name, '^[[:ascii:]]+$');
DROP TEMPORARY TABLE IF EXISTS tmp_moonwell_character_renames;
CREATE TEMPORARY TABLE tmp_moonwell_character_renames AS
SELECT t.guid, t.original_name, n.name AS localized_name
FROM tmp_moonwell_bot_targets t
JOIN tmp_moonwell_available_names n
ON n.gender = t.name_category
AND n.row_number_in_category = t.row_number_in_category;
START TRANSACTION;
INSERT IGNORE INTO moonwell_playerbot_name_backup (guid, original_name, localized_name)
SELECT guid, original_name, localized_name
FROM tmp_moonwell_character_renames;
UPDATE characters c
JOIN moonwell_playerbot_name_backup b ON b.guid = c.guid
SET c.name = b.localized_name
WHERE c.name = b.original_name;
DELETE declined
FROM character_declinedname declined
JOIN moonwell_playerbot_name_backup b ON b.guid = declined.guid;
COMMIT;
DROP TEMPORARY TABLE IF EXISTS tmp_moonwell_available_guild_names;
CREATE TEMPORARY TABLE tmp_moonwell_available_guild_names AS
SELECT
n.name,
ROW_NUMBER() OVER (ORDER BY n.name_id) AS row_number_in_pool
FROM playerbots_guild_names_ru n
LEFT JOIN guild used ON used.name = n.name
LEFT JOIN moonwell_playerbot_guild_name_backup reserved
ON reserved.localized_name = n.name
WHERE used.guildid IS NULL
AND reserved.guildid IS NULL;
DROP TEMPORARY TABLE IF EXISTS tmp_moonwell_guild_targets;
CREATE TEMPORARY TABLE tmp_moonwell_guild_targets AS
SELECT
g.guildid,
g.name AS original_name,
ROW_NUMBER() OVER (ORDER BY g.guildid) AS row_number_in_pool
FROM guild g
JOIN characters leader ON leader.guid = g.leaderguid
JOIN acore_playerbots.playerbots_account_type pat
ON pat.account_id = leader.account AND pat.account_type = 1
WHERE REGEXP_LIKE(g.name, '^[[:ascii:]]+$');
START TRANSACTION;
INSERT IGNORE INTO moonwell_playerbot_guild_name_backup (guildid, original_name, localized_name)
SELECT t.guildid, t.original_name, n.name
FROM tmp_moonwell_guild_targets t
JOIN tmp_moonwell_available_guild_names n
ON n.row_number_in_pool = t.row_number_in_pool;
UPDATE guild g
JOIN moonwell_playerbot_guild_name_backup b ON b.guildid = g.guildid
SET g.name = b.localized_name
WHERE g.name = b.original_name;
COMMIT;
"
read -r renamed_bot_count renamed_guild_count < <(
mysql_query acore_characters "
SELECT
(SELECT COUNT(*) FROM moonwell_playerbot_name_backup),
(SELECT COUNT(*) FROM moonwell_playerbot_guild_name_backup);
"
)
echo "Localized RNDbot characters: $renamed_bot_count"
echo "Localized RNDbot-led guilds: $renamed_guild_count"
echo "Static ruRU texts and Russian name pools are installed."
@@ -17,6 +17,7 @@
#include "QuestDef.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
#include "WorldSession.h"
#include <cstdlib>
#include <string>
@@ -31,6 +32,8 @@ namespace MoonWell::PlayerGuide
return;
if (!player || !player->GetSession() || player->IsBeingTeleported())
return;
if (player->GetSession()->IsBot())
return;
Payload p = sPlayerGuideMgr->BuildFor(player);
std::string json = sPlayerGuideMgr->SerializeJson(p);
@@ -25,6 +25,29 @@ namespace MoonWell::PlayerGuide
// The mgr clamps its configured chunk size into a safe range.
constexpr std::size_t HARD_LIMIT = 250;
std::size_t Utf8ChunkSize(std::string_view body, std::size_t offset,
std::size_t budget)
{
std::size_t take = std::min(budget, body.size() - offset);
if (offset + take == body.size())
return take;
// A chunk must not end between a UTF-8 leading byte and one of
// its continuation bytes. Chat packet validation rejects such
// an individually invalid string before the addon can reassemble
// the complete JSON payload.
while (take > 0 &&
(static_cast<unsigned char>(body[offset + take]) & 0xC0) == 0x80)
{
--take;
}
// The configured budget is clamped to at least 64 bytes, so a
// valid UTF-8 code point always fits. Keep a defensive fallback
// for malformed input to guarantee forward progress.
return take > 0 ? take : std::min(budget, body.size() - offset);
}
void SendOneChunk(Player* player, std::string_view command,
uint32 seq, uint32 total, std::string_view body)
{
@@ -71,16 +94,20 @@ namespace MoonWell::PlayerGuide
return;
}
// Multi-chunk: split blindly by byte budget. JSON tolerates
// concatenation on the client.
uint32 total = static_cast<uint32>(
(body.size() + budget - 1) / budget);
uint32 seq = 1;
for (std::size_t off = 0; off < body.size(); off += budget, ++seq)
// Calculate UTF-8-safe slices first because backing up from a
// continuation byte can increase the number of chunks.
std::vector<std::string_view> chunks;
for (std::size_t off = 0; off < body.size();)
{
std::size_t take = std::min(budget, body.size() - off);
SendOneChunk(player, command, seq, total,
std::string_view(body.data() + off, take));
std::size_t take = Utf8ChunkSize(body, off, budget);
chunks.emplace_back(body.data() + off, take);
off += take;
}
uint32 total = static_cast<uint32>(chunks.size());
for (uint32 i = 0; i < total; ++i)
{
SendOneChunk(player, command, i + 1, total, chunks[i]);
}
}
@@ -84,6 +84,10 @@
# Enable or disable Playerbots module
AiPlayerbot.Enabled = 1
# Use the MoonWell Cyrillic name and guild-name pools.
# Existing RNDbot characters can be migrated with localize-playerbots-ru.sh.
AiPlayerbot.UseRussianNames = 0
# Enable randombot system
AiPlayerbot.RandomBotAutologin = 1
@@ -12,6 +12,7 @@
#include "Event.h"
#include "PlayerbotTextMgr.h"
#include "Playerbots.h"
#include "Util.h"
static const std::unordered_set<std::string> noReplyMsgs = {
"join",
@@ -52,6 +53,18 @@ static const std::unordered_set<std::string> noReplyMsgParts = {
"+", "-", "@", "follow target", "focus heal", "cast ", "accept [", "e [", "destroy [", "go zone"};
static const std::unordered_set<std::string> noReplyMsgStarts = {"e ", "accept ", "cast ", "destroy "};
static std::string Utf8Lowercase(std::string const& value)
{
std::wstring wide;
if (!Utf8toWStr(value, wide))
return value;
wstrToLower(wide);
std::string lowered;
WStrToUtf8(wide, lowered);
return lowered;
}
SayAction::SayAction(PlayerbotAI* botAI) : Action(botAI, "say"), Qualified() {}
bool SayAction::Execute(Event /*event*/)
@@ -578,6 +591,40 @@ std::string ChatReplyAction::GenerateReplyMessage(Player* bot, std::string& inco
std::string respondsText = "";
// The legacy free-chat parser below is English-specific and constructs
// several responses directly in English. For ruRU, classify the small
// static reply set locally and let PlayerbotTextMgr select text_loc8.
if (PlayerbotTextMgr::instance().GetLocalePriority() == LOCALE_ruRU)
{
std::string const message = Utf8Lowercase(incomingMessage);
std::string const botName = Utf8Lowercase(bot->GetName());
if (Player* player = ObjectAccessor::FindPlayer(ObjectGuid(HighGuid::Player, guid1));
player && player->isGMChat())
{
replyType = REPLY_ADMIN_ABUSE;
}
else if (message.find("привет") != std::string::npos || message.find("здравств") != std::string::npos ||
message.find("добрый день") != std::string::npos || message.find("добрый вечер") != std::string::npos)
{
replyType = REPLY_HELLO;
}
else if (message.find("нуб") != std::string::npos || message.find("дурак") != std::string::npos ||
message.find("идиот") != std::string::npos || message.find("заткнись") != std::string::npos)
{
replyType = REPLY_GRUDGE;
}
else if (message.find(botName) != std::string::npos)
{
replyType = REPLY_NAME;
}
respondsText = PlayerbotTextMgr::instance().GetBotText(replyType, name);
if (respondsText.size() > 255)
respondsText.resize(255);
return respondsText;
}
// Chat Logic
int32 verb_pos = -1;
int32 verb_type = -1;
@@ -175,16 +175,17 @@ Player* RandomPlayerbotFactory::CreateRandomBot(WorldSession* session, uint8 cls
std::string const RandomPlayerbotFactory::CreateRandomBotName(NameRaceAndGender raceAndGender)
{
std::string botName = "";
char const* namesTable = sPlayerbotAIConfig.useRussianNames ? "playerbots_names_ru" : "playerbots_names";
int tries = 3;
while (--tries)
{
QueryResult result = CharacterDatabase.Query(
"SELECT n.name "
"FROM playerbots_names n "
"FROM {} n "
"LEFT OUTER JOIN characters c ON c.name = n.name "
"WHERE c.guid IS NULL and n.gender = '{}' "
"ORDER BY RAND() LIMIT 1",
static_cast<uint8>(raceAndGender));
namesTable, static_cast<uint8>(raceAndGender));
if (!result)
{
break;
@@ -668,7 +669,8 @@ void RandomPlayerbotFactory::CreateRandomBots()
{
nameCached = true;
LOG_INFO("playerbots", "Creating cache for names per gender and race...");
QueryResult result = CharacterDatabase.Query("SELECT name, gender FROM playerbots_names");
char const* namesTable = sPlayerbotAIConfig.useRussianNames ? "playerbots_names_ru" : "playerbots_names";
QueryResult result = CharacterDatabase.Query("SELECT name, gender FROM {}", namesTable);
if (!result)
{
LOG_ERROR("playerbots", "No more unused names left");
@@ -753,8 +755,10 @@ void RandomPlayerbotFactory::CreateRandomBots()
std::string const RandomPlayerbotFactory::CreateRandomGuildName()
{
std::string guildName = "";
char const* guildNamesTable =
sPlayerbotAIConfig.useRussianNames ? "playerbots_guild_names_ru" : "playerbots_guild_names";
QueryResult result = CharacterDatabase.Query("SELECT MAX(name_id) FROM playerbots_guild_names");
QueryResult result = CharacterDatabase.Query("SELECT MAX(name_id) FROM {}", guildNamesTable);
if (!result)
{
LOG_ERROR("playerbots", "No more names left for random guilds");
@@ -766,9 +770,9 @@ std::string const RandomPlayerbotFactory::CreateRandomGuildName()
uint32 id = urand(0, maxId);
result = CharacterDatabase.Query(
"SELECT n.name FROM playerbots_guild_names n "
"SELECT n.name FROM {} n "
"LEFT OUTER JOIN guild e ON e.name = n.name WHERE e.guildid IS NULL AND n.name_id >= {} LIMIT 1",
id);
guildNamesTable, id);
if (!result)
{
LOG_ERROR("playerbots", "No more names left for random guilds");
@@ -1652,7 +1652,7 @@ void PlayerbotMgr::OnPlayerLogin(Player* player)
// For bot texts (DB-driven), prefer the database locale with a safe fallback.
LocaleConstant usedLocale = databaseLocale;
if (usedLocale >= MAX_LOCALES)
if (usedLocale >= TOTAL_LOCALES)
usedLocale = LOCALE_enUS; // fallback
// set locale priority for bot texts
@@ -161,13 +161,15 @@ void PlayerbotGuildMgr::ResetGuildCache()
void PlayerbotGuildMgr::LoadGuildNames()
{
LOG_INFO("playerbots", "Loading guild names from playerbots_guild_names...");
char const* guildNamesTable =
sPlayerbotAIConfig.useRussianNames ? "playerbots_guild_names_ru" : "playerbots_guild_names";
LOG_INFO("playerbots", "Loading guild names from {}...", guildNamesTable);
QueryResult result = CharacterDatabase.Query("SELECT name_id, name FROM playerbots_guild_names");
QueryResult result = CharacterDatabase.Query("SELECT name_id, name FROM {}", guildNamesTable);
if (!result)
{
LOG_ERROR("playerbots", "No entries found in playerbots_guild_names. List is empty.");
LOG_ERROR("playerbots", "No entries found in {}. List is empty.", guildNamesTable);
return;
}
@@ -184,7 +186,7 @@ void PlayerbotGuildMgr::LoadGuildNames()
std::mt19937 g(rd());
std::shuffle(_shuffled_guild_keys.begin(), _shuffled_guild_keys.end(), g);
LOG_INFO("playerbots", "Loaded {} guild entries from playerbots_guild_names table.", _guildNames.size());
LOG_INFO("playerbots", "Loaded {} guild entries from {} table.", _guildNames.size(), guildNamesTable);
}
void PlayerbotGuildMgr::ValidateGuildCache()
@@ -38,7 +38,7 @@ void PlayerbotTextMgr::LoadBotTexts()
text[0] = fields[1].Get<std::string>();
uint8 sayType = fields[2].Get<uint8>();
uint8 replyType = fields[3].Get<uint8>();
for (uint8 i = 1; i < MAX_LOCALES; ++i)
for (uint8 i = 1; i < TOTAL_LOCALES; ++i)
{
text[i] = fields[i + 3].Get<std::string>();
}
@@ -192,9 +192,10 @@ bool PlayerbotTextMgr::GetBotText(std::string name, std::string& text, std::map<
void PlayerbotTextMgr::AddLocalePriority(uint32 locale)
{
if (locale >= MAX_LOCALES)
if (locale >= TOTAL_LOCALES)
{
LOG_WARN("playerbots", "Ignoring locale {} for bot texts because it exceeds MAX_LOCALES ({})", locale, MAX_LOCALES - 1);
LOG_WARN("playerbots", "Ignoring locale {} for bot texts because it exceeds TOTAL_LOCALES ({})", locale,
TOTAL_LOCALES - 1);
return;
}
@@ -212,7 +213,7 @@ uint32 PlayerbotTextMgr::GetLocalePriority()
}
uint32 topLocale = 0;
for (uint8 i = 0; i < MAX_LOCALES; ++i)
for (uint8 i = 0; i < TOTAL_LOCALES; ++i)
{
if (botTextLocalePriority[i] > botTextLocalePriority[topLocale])
topLocale = i;
@@ -223,7 +224,7 @@ uint32 PlayerbotTextMgr::GetLocalePriority()
void PlayerbotTextMgr::ResetLocalePriority()
{
for (uint8 i = 0; i < MAX_LOCALES; ++i)
for (uint8 i = 0; i < TOTAL_LOCALES; ++i)
{
botTextLocalePriority[i] = 0;
}
@@ -87,7 +87,7 @@ public:
private:
PlayerbotTextMgr()
{
for (uint8 i = 0; i < MAX_LOCALES; ++i)
for (uint8 i = 0; i < TOTAL_LOCALES; ++i)
{
botTextLocalePriority[i] = 0;
}
@@ -102,7 +102,7 @@ private:
std::map<std::string, std::vector<BotTextEntry>> botTexts;
std::map<std::string, uint32> botTextChance;
uint32 botTextLocalePriority[MAX_LOCALES];
uint32 botTextLocalePriority[TOTAL_LOCALES];
};
#endif
@@ -67,6 +67,8 @@ bool PlayerbotAIConfig::Initialize()
return false;
}
useRussianNames = sConfigMgr->GetOption<bool>("AiPlayerbot.UseRussianNames", false);
globalCoolDown = sConfigMgr->GetOption<int32>("AiPlayerbot.GlobalCooldown", 500);
maxWaitForMove = sConfigMgr->GetOption<int32>("AiPlayerbot.MaxWaitForMove", 5000);
disableMoveSplinePath = sConfigMgr->GetOption<int32>("AiPlayerbot.DisableMoveSplinePath", 0);
@@ -79,6 +79,7 @@ public:
bool IsInPvpProhibitedArea(uint32 id);
bool enabled;
bool useRussianNames;
bool disabledWithoutRealPlayer;
bool EnableICCBuffs;
bool allowAccountBots, allowGuildBots, allowTrustedAccountBots;
File diff suppressed because it is too large Load Diff
+18
View File
@@ -162,10 +162,16 @@ set -a
source "$ENV_FILE"
set +a
PLAYERBOTS_RUSSIAN_NAMES="${ACORE_PLAYERBOTS_RUSSIAN_NAMES:-1}"
cd "$ROOT_DIR"
require_docker
if ! [[ "$PLAYERBOTS_RUSSIAN_NAMES" =~ ^[01]$ ]]; then
die "ACORE_PLAYERBOTS_RUSSIAN_NAMES must be 0 or 1, got: $PLAYERBOTS_RUSSIAN_NAMES"
fi
if [[ $EUID -eq 0 ]]; then
apply_sysctl
else
@@ -192,12 +198,24 @@ if ! wait_for_healthy "ac-database" 180; then
die "ac-database did not become healthy"
fi
if [[ "$PLAYERBOTS_RUSSIAN_NAMES" == "1" ]]; then
log "stopping worldserver before playerbot localization"
$COMPOSE stop ac-worldserver
fi
log "running database import"
run_one_shot_service "ac-db-import" "ac-db-import"
log "importing custom SQL"
bash "$ROOT_DIR/import-custom-sql.sh"
if [[ "$PLAYERBOTS_RUSSIAN_NAMES" == "1" ]]; then
log "localizing playerbot names, guilds, and chat texts"
bash "$ROOT_DIR/localize-playerbots-ru.sh" --apply
else
log "playerbot Russian localization disabled"
fi
log "applying post-database module setup"
bash "$ROOT_DIR/setup-modules.sh"
+6
View File
@@ -106,11 +106,14 @@ FORCE_PLAYERBOTS="${FORCE_PLAYERBOTS:-${ACORE_PLAYERBOTS_FORCE:-0}}"
: "${ACORE_LEARNSPELLS_RIDING_COLD_WEATHER_FLYING:=0}"
: "${ACORE_PLAYERBOTS_ENABLED:=1}"
: "${ACORE_PLAYERBOTS_RUSSIAN_NAMES:=1}"
: "${ACORE_PLAYERBOTS_RANDOM_BOT_TALK:=1}"
: "${ACORE_PLAYERBOTS_RANDOM_BOT_AUTOLOGIN:=${AC_AI_PLAYERBOT_RANDOM_BOT_AUTOLOGIN:-0}}"
: "${ACORE_PLAYERBOTS_MIN_RANDOM_BOTS:=0}"
: "${ACORE_PLAYERBOTS_MAX_RANDOM_BOTS:=0}"
: "${ACORE_PLAYERBOTS_RANDOM_BOT_ACCOUNT_COUNT:=0}"
: "${ACORE_PLAYERBOTS_DELETE_RANDOM_BOT_ACCOUNTS:=0}"
: "${ACORE_PLAYERBOTS_DELETE_RANDOM_BOT_GUILDS:=0}"
: "${ACORE_PLAYERBOTS_MAX_ADDED_BOTS:=9}"
: "${ACORE_PLAYERBOTS_ADDCLASS_COMMAND:=1}"
: "${ACORE_PLAYERBOTS_ADDCLASS_ACCOUNT_POOL_SIZE:=3}"
@@ -530,11 +533,14 @@ write_playerbots_conf() {
write_file "$MODULES_ETC_DIR/playerbots.conf.dist" <<EOF
# Managed by ./setup-modules.sh
AiPlayerbot.Enabled = ${ACORE_PLAYERBOTS_ENABLED}
AiPlayerbot.UseRussianNames = ${ACORE_PLAYERBOTS_RUSSIAN_NAMES}
AiPlayerbot.RandomBotTalk = ${ACORE_PLAYERBOTS_RANDOM_BOT_TALK}
AiPlayerbot.RandomBotAutologin = ${ACORE_PLAYERBOTS_RANDOM_BOT_AUTOLOGIN}
AiPlayerbot.MinRandomBots = ${ACORE_PLAYERBOTS_MIN_RANDOM_BOTS}
AiPlayerbot.MaxRandomBots = ${ACORE_PLAYERBOTS_MAX_RANDOM_BOTS}
AiPlayerbot.RandomBotAccountCount = ${ACORE_PLAYERBOTS_RANDOM_BOT_ACCOUNT_COUNT}
AiPlayerbot.DeleteRandomBotAccounts = ${ACORE_PLAYERBOTS_DELETE_RANDOM_BOT_ACCOUNTS}
AiPlayerbot.DeleteRandomBotGuilds = ${ACORE_PLAYERBOTS_DELETE_RANDOM_BOT_GUILDS}
AiPlayerbot.DisabledWithoutRealPlayer = ${ACORE_PLAYERBOTS_DISABLED_WITHOUT_REAL_PLAYER}
AiPlayerbot.MaxAddedBots = ${ACORE_PLAYERBOTS_MAX_ADDED_BOTS}
AiPlayerbot.AddClassCommand = ${ACORE_PLAYERBOTS_ADDCLASS_COMMAND}
+20
View File
@@ -14,6 +14,8 @@ if [[ -f "$ENV_FILE" ]]; then
set +a
fi
PLAYERBOTS_RUSSIAN_NAMES="${ACORE_PLAYERBOTS_RUSSIAN_NAMES:-1}"
usage() {
cat <<EOF
Usage: $(basename "$0") [--no-build] [--logs]
@@ -167,6 +169,12 @@ cd "$ROOT_DIR"
require_docker
if ! [[ "$PLAYERBOTS_RUSSIAN_NAMES" =~ ^[01]$ ]]; then
printf 'ACORE_PLAYERBOTS_RUSSIAN_NAMES must be 0 or 1, got: %s\n' \
"$PLAYERBOTS_RUSSIAN_NAMES" >&2
exit 1
fi
log "applying module configuration"
bash "$ROOT_DIR/setup-modules.sh"
@@ -186,12 +194,24 @@ if ! wait_for_healthy "ac-database" 180; then
exit 1
fi
if [[ "$PLAYERBOTS_RUSSIAN_NAMES" == "1" ]]; then
log "stopping worldserver before playerbot localization"
docker compose stop ac-worldserver
fi
log "running database import"
run_one_shot_service "ac-db-import" "ac-db-import"
log "importing custom SQL"
bash "$ROOT_DIR/import-custom-sql.sh"
if [[ "$PLAYERBOTS_RUSSIAN_NAMES" == "1" ]]; then
log "localizing playerbot names, guilds, and chat texts"
bash "$ROOT_DIR/localize-playerbots-ru.sh" --apply
else
log "playerbot Russian localization disabled"
fi
log "applying post-database module setup"
bash "$ROOT_DIR/setup-modules.sh"
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Build the static mod-playerbots ruRU SQL update from a reviewed TSV file."""
from __future__ import annotations
import csv
import re
from collections import Counter
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
INPUT = ROOT / "playerbots-phrases-ru-reviewed.tsv"
OUTPUT = (
ROOT
/ "modules/mod-playerbots/data/sql/playerbots/updates/"
/ "2026_07_30_00_ai_playerbot_russian_texts.sql"
)
EXPECTED_FIELDS = ("id", "key", "original_en", "translation_ru")
PLACEHOLDER_RE = re.compile(r"%[A-Za-z_][A-Za-z0-9_]*|<[^>]+>")
def sql_quote(value: str) -> str:
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
def load_rows() -> list[dict[str, str]]:
with INPUT.open(encoding="utf-8-sig", newline="") as input_file:
reader = csv.DictReader(input_file, delimiter="\t")
if tuple(reader.fieldnames or ()) != EXPECTED_FIELDS:
raise RuntimeError(
f"Unexpected TSV columns: {reader.fieldnames}; "
f"expected {EXPECTED_FIELDS}"
)
rows = list(reader)
if not rows:
raise RuntimeError(f"No translations found in {INPUT}")
seen_ids: set[int] = set()
for line_number, row in enumerate(rows, start=2):
try:
row_id = int(row["id"])
except ValueError as error:
raise RuntimeError(
f"{INPUT}:{line_number}: invalid id {row['id']!r}"
) from error
if row_id in seen_ids:
raise RuntimeError(f"{INPUT}:{line_number}: duplicate id {row_id}")
seen_ids.add(row_id)
if not row["key"]:
raise RuntimeError(f"{INPUT}:{line_number}: empty key")
if not row["translation_ru"].strip():
raise RuntimeError(f"{INPUT}:{line_number}: empty translation")
source_placeholders = Counter(PLACEHOLDER_RE.findall(row["original_en"]))
target_placeholders = Counter(PLACEHOLDER_RE.findall(row["translation_ru"]))
if source_placeholders != target_placeholders:
raise RuntimeError(
f"{INPUT}:{line_number}: placeholder mismatch: "
f"{source_placeholders} != {target_placeholders}"
)
return rows
def write_sql(rows: list[dict[str, str]]) -> None:
lines = [
"-- MoonWell static Russian localization for mod-playerbots.",
f"-- Generated from {INPUT.name}; edit the TSV and rebuild, not this file.",
"-- Placeholder names and counts are validated; their order may differ in Russian.",
"SET NAMES utf8mb4;",
"",
"START TRANSACTION;",
]
for row in rows:
lines.append(
"UPDATE `ai_playerbot_texts` "
f"SET `text_loc8`={sql_quote(row['translation_ru'])} "
f"WHERE `id`={int(row['id'])} AND `name`={sql_quote(row['key'])};"
)
lines.extend(["COMMIT;", ""])
OUTPUT.write_text("\n".join(lines), encoding="utf-8")
print(f"Wrote {len(rows)} reviewed translations to {OUTPUT}")
def main() -> None:
write_sql(load_rows())
if __name__ == "__main__":
main()
+64
View File
@@ -0,0 +1,64 @@
#!/usr/bin/env python3
"""Export all playerbot chat phrases for manual Russian translation review."""
from __future__ import annotations
import csv
import subprocess
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = ROOT / "doc" / "playerbots-phrases-ru-review.tsv"
def load_rows() -> list[tuple[str, str, str, str]]:
sql = (
"SELECT id,HEX(name),HEX(text),HEX(COALESCE(text_loc8,'')) "
"FROM ai_playerbot_texts ORDER BY id"
)
command = [
"docker",
"compose",
"exec",
"-T",
"ac-database",
"bash",
"-lc",
f'mysql -N -s -uroot -p"$MYSQL_ROOT_PASSWORD" '
f'acore_playerbots -e "{sql}" 2>/dev/null',
]
output = subprocess.check_output(command, cwd=ROOT, text=True)
rows: list[tuple[str, str, str, str]] = []
for line in output.splitlines():
parts = line.split("\t")
if len(parts) != 4:
continue
row_id, name_hex, source_hex, russian_hex = parts
rows.append(
(
row_id,
bytes.fromhex(name_hex).decode("utf-8"),
bytes.fromhex(source_hex).decode("utf-8", errors="replace"),
bytes.fromhex(russian_hex).decode("utf-8", errors="replace"),
)
)
return rows
def main() -> None:
rows = load_rows()
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
with OUTPUT.open("w", encoding="utf-8", newline="") as output:
writer = csv.writer(output, delimiter="\t", quoting=csv.QUOTE_MINIMAL)
writer.writerow(("id", "key", "original_en", "translation_ru"))
writer.writerows(rows)
print(f"Wrote {len(rows)} phrases to {OUTPUT}")
if __name__ == "__main__":
main()
+314
View File
@@ -0,0 +1,314 @@
#!/usr/bin/env python3
"""Generate deterministic Cyrillic playerbot character and guild name pools."""
from __future__ import annotations
import itertools
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = (
ROOT
/ "modules/mod-playerbots/data/sql/characters/updates/"
/ "2026_07_30_00_playerbots_russian_names.sql"
)
NAMES_PER_CATEGORY = 250
UPSTREAM_NAMES = (
ROOT
/ "modules/mod-playerbots/data/sql/characters/base/playerbots_names.sql"
)
CATEGORIES = {
0: (
["Ал", "Бор", "Вел", "Влад", "Град", "Дар", "Мир", "Рад", "Свят", "Яр"],
["", "о", "е", "и", "а"],
["ан", "ен", "ий", "ор", "слав", "мир", "дан", "бор", "гор", "вит"],
),
1: (
["Аль", "Бож", "Вес", "Дар", "Злат", "Лад", "Люб", "Мил", "Рад", "Яр"],
["", "о", "е", "и", "а"],
["ана", "ена", "ина", "ира", "исса", "лава", "мира", "яна", "ея", "ора"],
),
2: (
["Блик", "Винт", "Гайк", "Жуж", "Искр", "Клап", "Порш", "Рыч", "Тик", "Шпун"],
["", "о", "е", "и", "а"],
["ик", "ак", "ун", "ил", "ер", "орт", "ен", "ос", "ин", "ей"],
),
3: (
["Блик", "Винт", "Гайк", "Жуж", "Искр", "Кноп", "Пруж", "Тик", "Шпул", "Шестер"],
["", "о", "е", "и", "а"],
["ика", "ана", "инка", "елла", "ира", "ина", "етта", "исса", "уна", "ея"],
),
4: (
["Бран", "Бром", "Гим", "Дор", "Каз", "Краг", "Мор", "Тор", "Фар", "Хар"],
["", "о", "е", "и", "а"],
["дин", "гар", "грим", "мунд", "бор", "рик", "вар", "драм", "гран", "ор"],
),
5: (
["Бран", "Бром", "Гим", "Дор", "Каз", "Краг", "Мор", "Тор", "Фар", "Хель"],
["", "о", "е", "и", "а"],
["дина", "гара", "гримма", "мунда", "бора", "рика", "вара", "дра", "грана", "ина"],
),
6: (
["Аэл", "Илл", "Лиар", "Мэл", "Ним", "Саэл", "Тал", "Фаэр", "Эли", "Яв"],
["", "о", "е", "и", "а"],
["дор", "рион", "лас", "тир", "ниэл", "вен", "рон", "лир", "тис", "эль"],
),
7: (
["Аэл", "Илл", "Лиа", "Мэл", "Ниа", "Саэл", "Тали", "Фаэ", "Эли", "Яви"],
["", "о", "е", "и", "а"],
["дора", "риэль", "ласса", "тира", "ниэль", "вена", "рона", "лира", "тисса", "эля"],
),
8: (
["Ака", "Ару", "Ваа", "Ири", "Каа", "Наэ", "Оро", "Таа", "Эре", "Яна"],
["", "л", "р", "н", "м"],
["дор", "ниус", "мар", "рион", "тар", "вен", "мон", "дар", "рос", "эль"],
),
9: (
["Ака", "Ару", "Ваа", "Ири", "Каа", "Наэ", "Оро", "Таа", "Эре", "Яна"],
["", "л", "р", "н", "м"],
["дора", "ния", "мара", "риэль", "тара", "вена", "мона", "дара", "роса", "эла"],
),
10: (
["Гар", "Гром", "Драк", "Карг", "Мок", "Рок", "Тар", "Ург", "Хар", "Зуг"],
["", "о", "а", "у", "и"],
["аш", "мак", "гар", "док", "рат", "гул", "нак", "рок", "тар", "зог"],
),
11: (
["Гра", "Гром", "Дра", "Кар", "Мо", "Ро", "Тар", "Ур", "Хар", "Зу"],
["", "о", "а", "у", "и"],
["ша", "мака", "гара", "дока", "рата", "гула", "нака", "рока", "тара", "зога"],
),
12: (
["Вол", "Джин", "Зан", "Зул", "Каз", "Раз", "Сэн", "Таз", "Хак", "Ям"],
["", "а", "о", "и", "у"],
["джи", "зар", "кан", "мон", "рак", "тал", "хан", "зин", "джо", "мар"],
),
13: (
["Вола", "Джи", "Зана", "Зула", "Каза", "Раза", "Сэна", "Таза", "Хака", "Яма"],
["", "а", "о", "и", "у"],
["джи", "зара", "кана", "мона", "рака", "тала", "хана", "зина", "джа", "мара"],
),
14: (
["Гром", "Кам", "Коп", "Неб", "Рог", "Степ", "Туч", "Шрам", "Ветр", "Биз"],
["", "о", "е", "и", "а"],
["орог", "оступ", "ебык", "огрив", "окоп", "оступ", "ешаг", "арог", "овет", "он"],
),
15: (
["Гром", "Кам", "Коп", "Неб", "Рог", "Степ", "Туч", "Шрам", "Ветр", "Биз"],
["", "о", "е", "и", "а"],
["орогая", "оступа", "ебыка", "огрива", "окопа", "ешага", "арога", "овета", "она", "ани"],
),
16: (
["Алер", "Вей", "Каэл", "Кел", "Лор", "Сал", "Тал", "Фен", "Эль", "Эрен"],
["", "а", "е", "и", "о"],
["ан", "ион", "дир", "лас", "рен", "тос", "вар", "лен", "рис", "эль"],
),
17: (
["Але", "Вей", "Каэ", "Кели", "Лори", "Сали", "Тали", "Фе", "Эли", "Эре"],
["", "а", "е", "и", "о"],
["ана", "иэль", "дира", "ласса", "рена", "тоса", "вара", "лена", "риса", "эля"],
),
}
GUILD_PREFIXES = [
"Стражи", "Клинки", "Герои", "Хранители", "Вестники", "Воины",
"Дети", "Сыны", "Дочери", "Рыцари", "Искатели", "Защитники",
]
GUILD_OBJECTS = [
"Рассвета", "Заката", "Севера", "Юга", "Азерота", "Калимдора",
"Бури", "Пламени", "Льда", "Теней", "Луны", "Солнца",
"Чести", "Славы", "Свободы", "Надежды", "Дракона", "Ворона",
]
GUILD_ADJECTIVES = [
"Лунный", "Стальной", "Серебряный", "Золотой", "Алый", "Сумрачный",
"Ночной", "Северный", "Вечный", "Тихий", "Грозовой", "Огненный",
]
GUILD_NOUNS = [
"Дозор", "Легион", "Орден", "Союз", "Круг", "Щит",
"Клинок", "Рубеж", "Рассвет", "Оплот", "Поход", "Ветер",
]
def valid_name(value: str) -> bool:
return (
2 <= len(value) <= 12
and value.isalpha()
and all("А" <= char <= "я" or char in "Ёё" for char in value)
and not any(value[index] == value[index - 1] == value[index - 2] for index in range(2, len(value)))
)
TRANSLITERATION = {
"shch": "щ",
"sch": "щ",
"zh": "ж",
"kh": "х",
"ts": "ц",
"ch": "ч",
"sh": "ш",
"th": "т",
"ph": "ф",
"qu": "кв",
"ck": "к",
"ya": "я",
"yo": "ё",
"yu": "ю",
"a": "а",
"b": "б",
"c": "к",
"d": "д",
"e": "е",
"f": "ф",
"g": "г",
"h": "х",
"i": "и",
"j": "дж",
"k": "к",
"l": "л",
"m": "м",
"n": "н",
"o": "о",
"p": "п",
"q": "к",
"r": "р",
"s": "с",
"t": "т",
"u": "у",
"v": "в",
"w": "в",
"x": "кс",
"y": "й",
"z": "з",
}
def transliterate_name(value: str) -> str:
source = value.casefold()
translated: list[str] = []
index = 0
keys = sorted(TRANSLITERATION, key=len, reverse=True)
while index < len(source):
for key in keys:
if source.startswith(key, index):
translated.append(TRANSLITERATION[key])
index += len(key)
break
else:
return ""
return "".join(translated).capitalize()
def mysql_unicode_ci_key(value: str) -> str:
"""Approximate the equivalences used by the target utf8mb4_unicode_ci index."""
return value.casefold().replace("ё", "е").replace("й", "и")
def generate_character_names() -> list[tuple[str, int]]:
used: set[str] = set()
result: list[tuple[str, int]] = []
by_category: dict[int, list[str]] = {category: [] for category in range(18)}
row_pattern = re.compile(r"^\(\d+,'([^']+)',(\d+)\)[,;]$")
for line in UPSTREAM_NAMES.read_text(encoding="utf-8").splitlines():
match = row_pattern.match(line.strip())
if not match:
continue
original, category_text = match.groups()
category = int(category_text)
if category not in by_category or len(by_category[category]) >= NAMES_PER_CATEGORY:
continue
candidate = transliterate_name(original)
candidate_key = mysql_unicode_ci_key(candidate)
if not valid_name(candidate) or candidate_key in used:
continue
used.add(candidate_key)
by_category[category].append(candidate)
for category, category_names in by_category.items():
if len(category_names) != NAMES_PER_CATEGORY:
raise RuntimeError(
f"Category {category} produced {len(category_names)} names, expected {NAMES_PER_CATEGORY}"
)
result.extend((name, category) for name in category_names)
return result
def generate_guild_names() -> list[str]:
candidates = [
f"{prefix} {obj}"
for prefix, obj in itertools.product(GUILD_PREFIXES, GUILD_OBJECTS)
]
candidates.extend(
f"{adjective} {noun}"
for adjective, noun in itertools.product(GUILD_ADJECTIVES, GUILD_NOUNS)
)
return list(dict.fromkeys(name for name in candidates if len(name) <= 24))
def sql_quote(value: str) -> str:
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
def main() -> None:
names = generate_character_names()
guild_names = generate_guild_names()
lines = [
"-- MoonWell static Cyrillic name pools for mod-playerbots.",
"-- Generated by tools/generate-playerbots-ru-names.py.",
"SET NAMES utf8mb4;",
"",
"CREATE TABLE IF NOT EXISTS `playerbots_names_ru` (",
" `name_id` INT UNSIGNED NOT NULL AUTO_INCREMENT,",
" `name` VARCHAR(24) NOT NULL,",
" `gender` TINYINT UNSIGNED NOT NULL,",
" PRIMARY KEY (`name_id`),",
" KEY `idx_playerbots_names_ru_name` (`name`)",
") ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;",
"",
"TRUNCATE TABLE `playerbots_names_ru`;",
"INSERT INTO `playerbots_names_ru` (`name`, `gender`) VALUES",
]
lines.extend(
f"({sql_quote(name)}, {category}){',' if index + 1 < len(names) else ';'}"
for index, (name, category) in enumerate(names)
)
lines.extend(
[
"",
"CREATE TABLE IF NOT EXISTS `playerbots_guild_names_ru` (",
" `name_id` INT UNSIGNED NOT NULL AUTO_INCREMENT,",
" `name` VARCHAR(24) NOT NULL,",
" PRIMARY KEY (`name_id`),",
" UNIQUE KEY `uq_playerbots_guild_names_ru_name` (`name`)",
") ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;",
"",
"TRUNCATE TABLE `playerbots_guild_names_ru`;",
"INSERT INTO `playerbots_guild_names_ru` (`name`) VALUES",
]
)
lines.extend(
f"({sql_quote(name)}){',' if index + 1 < len(guild_names) else ';'}"
for index, name in enumerate(guild_names)
)
lines.append("")
OUTPUT.parent.mkdir(parents=True, exist_ok=True)
OUTPUT.write_text("\n".join(lines), encoding="utf-8")
print(f"Wrote {len(names)} character names and {len(guild_names)} guild names to {OUTPUT}")
if __name__ == "__main__":
main()
+220
View File
@@ -0,0 +1,220 @@
#!/usr/bin/env python3
"""Generate the final static ruRU update for untranslated playerbot texts.
Run this after the playerbots database is fully updated. The generation-only
dependencies are intentionally not part of the server runtime:
pip install torch transformers sentencepiece sacremoses
"""
from __future__ import annotations
import os
import re
import subprocess
from collections import Counter
from pathlib import Path
import torch
from transformers import AutoModelForSeq2SeqLM, AutoTokenizer
ROOT = Path(__file__).resolve().parents[1]
OUTPUT = (
ROOT
/ "modules/mod-playerbots/data/sql/playerbots/updates/"
/ "2026_07_30_00_ai_playerbot_russian_texts.sql"
)
MODEL_NAME = os.environ.get(
"PLAYERBOTS_RU_TRANSLATION_MODEL",
"facebook/nllb-200-distilled-600M",
)
PLACEHOLDER_RE = re.compile(r"%[A-Za-z_][A-Za-z0-9_]*|<[^>]+>")
def load_untranslated_rows() -> list[tuple[int, str, str]]:
sql = (
"SELECT id,HEX(name),HEX(text) "
"FROM ai_playerbot_texts WHERE LENGTH(text_loc8)=0 ORDER BY id"
)
command = [
"docker",
"compose",
"exec",
"-T",
"ac-database",
"bash",
"-lc",
f'mysql -N -s -uroot -p"$MYSQL_ROOT_PASSWORD" acore_playerbots -e "{sql}" 2>/dev/null',
]
output = subprocess.check_output(command, cwd=ROOT, text=True)
rows: list[tuple[int, str, str]] = []
for line in output.splitlines():
parts = line.split("\t")
if len(parts) != 3:
continue
row_id = int(parts[0])
name = bytes.fromhex(parts[1]).decode("utf-8")
text = bytes.fromhex(parts[2]).decode("utf-8").replace("\ufffd", "'")
rows.append((row_id, name, text))
return rows
def sql_quote(value: str) -> str:
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
def prepare_source(text: str) -> str:
replacements = {
"Im": "I am",
"Ill": "I will",
"Ive": "I have",
"wont": "will not",
"cant": "cannot",
"dont": "do not",
"Lets": "Let us",
"Just killed %victim_name": "I have just defeated %victim_name",
"%victim_name was too easy": "Defeating %victim_name was too easy",
"More %faction rep": "More reputation with %faction",
"grinding %faction rep": "earning reputation with %faction",
"farm %category": "collect %category",
"farming %category": "collecting %category",
"looting": "collecting loot",
"hit me up": "message me",
"Hit me up": "Message me",
"hit level": "reached level",
"Hit level": "Reached level",
"turned it in": "handed it in",
"over nothing": "rather than getting nothing",
"Any takers": "Who wants",
"a solid ": "a good ",
"A solid ": "A good ",
"great deal": "good price",
"smack talk": "insults",
}
prepared = text
for source, target in replacements.items():
prepared = prepared.replace(source, target)
prepared = re.sub(r"\brep\b", "reputation", prepared, flags=re.IGNORECASE)
prepared = re.sub(r"\bgrinding\b", "earning", prepared, flags=re.IGNORECASE)
prepared = re.sub(r"\bgrind\b", "earn", prepared, flags=re.IGNORECASE)
prepared = re.sub(r"\baggro\b", "attract enemies", prepared, flags=re.IGNORECASE)
return prepared
def translate(rows: list[tuple[int, str, str]]) -> list[tuple[int, str, str, str]]:
is_nllb = "nllb" in MODEL_NAME.casefold()
tokenizer_options = {"src_lang": "eng_Latn"} if is_nllb else {}
tokenizer = AutoTokenizer.from_pretrained(MODEL_NAME, **tokenizer_options)
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
dtype = torch.float16 if device.type == "cuda" else torch.float32
model = AutoModelForSeq2SeqLM.from_pretrained(MODEL_NAME, torch_dtype=dtype).to(device)
batch_size = 48 if device.type == "cuda" else 12
result: list[tuple[int, str, str, str]] = []
def generate_texts(texts: list[str]) -> list[str]:
encoded = tokenizer(
texts,
return_tensors="pt",
padding=True,
truncation=True,
max_length=256,
)
encoded = {key: value.to(device) for key, value in encoded.items()}
generation_options = {
"max_new_tokens": 256,
"num_beams": 4,
"early_stopping": True,
}
if is_nllb:
generation_options["forced_bos_token_id"] = tokenizer.convert_tokens_to_ids("rus_Cyrl")
generated = model.generate(
**encoded,
**generation_options,
)
return tokenizer.batch_decode(generated, skip_special_tokens=True)
def translate_with_segments(source: str) -> str:
parts = re.split(f"({PLACEHOLDER_RE.pattern})", source)
jobs: list[tuple[int, str, str, str]] = []
for part_index, part in enumerate(parts):
if not part or PLACEHOLDER_RE.fullmatch(part):
continue
alpha_indexes = [index for index, char in enumerate(part) if char.isalpha()]
if not alpha_indexes:
continue
first_alpha = alpha_indexes[0]
last_alpha = alpha_indexes[-1]
jobs.append(
(
part_index,
part[:first_alpha],
prepare_source(part[first_alpha : last_alpha + 1]),
part[last_alpha + 1 :],
)
)
translated_parts = generate_texts([core for _index, _leading, core, _trailing in jobs])
for (part_index, leading, _core, trailing), target in zip(jobs, translated_parts):
parts[part_index] = leading + target + trailing
return "".join(parts)
for start in range(0, len(rows), batch_size):
batch = rows[start : start + batch_size]
translated = generate_texts(
[prepare_source(source) for _row_id, _name, source in batch]
)
for (row_id, name, source), target in zip(batch, translated):
source_placeholders = Counter(PLACEHOLDER_RE.findall(source))
target_placeholders = Counter(PLACEHOLDER_RE.findall(target))
if source_placeholders != target_placeholders:
target = translate_with_segments(source)
target_placeholders = Counter(PLACEHOLDER_RE.findall(target))
if source_placeholders != target_placeholders:
raise RuntimeError(
f"Placeholder mismatch for id={row_id}: "
f"{source_placeholders} != {target_placeholders}"
)
result.append((row_id, name, source, target))
print(
f"Translated {min(start + batch_size, len(rows))}/{len(rows)} rows",
flush=True,
)
return result
def write_sql(rows: list[tuple[int, str, str, str]]) -> None:
lines = [
"-- MoonWell static Russian localization for mod-playerbots.",
"-- Generated after all upstream playerbots text migrations.",
"-- Placeholders are validated by tools/generate-playerbots-ru-texts.py.",
"SET NAMES utf8mb4;",
"",
"START TRANSACTION;",
]
for row_id, name, _source, target in rows:
lines.append(
"UPDATE `ai_playerbot_texts` "
f"SET `text_loc8`={sql_quote(target)} "
f"WHERE `id`={row_id} AND `name`={sql_quote(name)} AND LENGTH(`text_loc8`)=0;"
)
lines.extend(["COMMIT;", ""])
OUTPUT.write_text("\n".join(lines), encoding="utf-8")
print(f"Wrote {len(rows)} translations to {OUTPUT}")
def main() -> None:
rows = load_untranslated_rows()
if not rows:
raise SystemExit("No untranslated playerbot text rows found")
write_sql(translate(rows))
if __name__ == "__main__":
main()