боты русифицированы
This commit is contained in:
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+4884
File diff suppressed because it is too large
Load Diff
+1746
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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"
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Binary file not shown.
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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 = {
|
||||
"I’m": "I am",
|
||||
"I’ll": "I will",
|
||||
"I’ve": "I have",
|
||||
"won’t": "will not",
|
||||
"can’t": "cannot",
|
||||
"don’t": "do not",
|
||||
"Let’s": "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()
|
||||
Reference in New Issue
Block a user