16 Commits

182 changed files with 254192 additions and 728 deletions
+27
View File
@@ -0,0 +1,27 @@
{
"permissions": {
"allow": [
"Bash(command -v lua5.1)",
"Bash(command -v lua)",
"Read(//home/sindo/azeroth-server/**)",
"Read(//home/sindo/**)",
"Bash(docker image *)",
"Bash(docker restart *)",
"Bash(bash import-custom-sql.sh)",
"Bash(awk '/^ ac-worldserver:/,/^ [a-z]/' /home/sindo/moonwell-core/docker-compose.yml)",
"Bash(awk '/ac-worldserver:/{flag=1} flag{print; if\\(/depends_on:/\\){flag=0}}' /home/sindo/moonwell-core/docker-compose.yml)",
"Bash(grep -n \"LoadAdditionalFile\\\\|\\\\.conf.dist\\\\|conf$\" /home/sindo/moonwell-core/src/common/Configuration/Config.cpp)",
"Bash(grep -n \"^$\\\\|ENCOUNTER\" /home/sindo/moonwell-core/conf/dist/env.ac)",
"Bash(grep -v \"^information_schema\\\\|^mysql$\\\\|^performance_schema\\\\|^sys$\")",
"Bash(python3 /home/sindo/moonwell-core/modules/mod-encounter-journal/tools/build_item_cache.py -j /home/sindo/moonwell-core/env/dist/logs/EncounterJournalData.json -d /home/sindo/moonwell-core/Generated_ItemsCache1.lua /home/sindo/moonwell-core/Generated_ItemsCache2.lua /home/sindo/moonwell-core/Generated_ItemsCache3.lua -o /tmp/EncounterJournalItemCache.lua)",
"Bash(awk 'NR>1 && !/^\\\\s*\\\\[[0-9]+\\\\]\\\\s*=\\\\s*\\\\{.*\\\\},?\\\\s*$/ && !/^\\\\}$/' /home/sindo/moonwell-core/Generated_ItemsCache3.lua)",
"Bash(awk '/^\\\\s*\\\\[[0-9]+\\\\]\\\\s*=\\\\s*\\\\{/' /home/sindo/moonwell-core/Generated_ItemsCache3.lua)",
"Bash(grep -n \"^[A-Z_]* = {$\\\\|^[A-Z_]* = {\" /home/sindo/moonwell-core/Generated_EncounterJournal.lua)",
"Bash(python3 /home/sindo/moonwell-core/modules/mod-encounter-journal/tools/import_donor_journal.py -i /home/sindo/moonwell-core/Generated_EncounterJournal.lua -o /tmp/donor_import.sql)",
"Bash(python3 /home/sindo/moonwell-core/modules/mod-encounter-journal/tools/import_donor_journal.py -i /home/sindo/moonwell-core/Generated_EncounterJournal.lua -o /tmp/donor_import_filtered.sql --known-maps /tmp/known_maps.txt --known-creatures /tmp/known_creatures.txt --known-items /tmp/known_items.txt)",
"Bash(sudo grep -c \"Encounter Journal loaded\\\\|mod-encounter-journal\" /var/lib/docker/containers/8a3d7a23f170597b5a8eae754ef3259ac6f2af352d78958707ef5cf3010133e3/8a3d7a23f170597b5a8eae754ef3259ac6f2af352d78958707ef5cf3010133e3-json.log)",
"Bash(xargs -I {} stat -c '%y %n' {})",
"Bash(grep -n \"INSERT INTO \\\\`custom_ej_instance\\\\`\" /home/sindo/moonwell-core/modules/mod-encounter-journal/data/sql/db-world/base/02_encounter_journal_sample.sql)"
]
}
}
+47
View File
@@ -65,6 +65,42 @@ AC_RATE_XP_KILL=1
# Legacy alias для docker override. Держим синхронно с ACORE_PLAYERBOTS_RANDOM_BOT_AUTOLOGIN.
AC_AI_PLAYERBOT_RANDOM_BOT_AUTOLOGIN=0
# ------------------------------------------------------------------------------
# Playerbots AI / Yandex AI Studio
# ------------------------------------------------------------------------------
# Включайте после заполнения YANDEX_AI_API_KEY и YANDEX_AI_FOLDER_ID.
ACORE_PLAYERBOTS_AI_ENABLED=0
# Секретный API-ключ сервисного аккаунта Yandex AI Studio.
# Не коммитьте заполненное значение.
YANDEX_AI_API_KEY=
# Идентификатор каталога Yandex Cloud.
YANDEX_AI_FOLDER_ID=
# Пусто = gpt://<YANDEX_AI_FOLDER_ID>/aliceai-llm-flash
YANDEX_AI_MODEL_URI=
YANDEX_AI_BASE_URL=https://ai.api.cloud.yandex.net/v1
# Параметры генерации и игрового rate limit.
PLAYERBOTS_AI_REQUEST_TIMEOUT=12
PLAYERBOTS_AI_TEMPERATURE=0.4
PLAYERBOTS_AI_MAX_TOKENS=120
PLAYERBOTS_AI_HISTORY_MESSAGES=6
PLAYERBOTS_AI_LOG_API_RESPONSES=1
# Постоянный кэш классификации команд. 604800 секунд = 7 дней.
PLAYERBOTS_AI_CLASSIFIER_CACHE_ENABLED=1
PLAYERBOTS_AI_CLASSIFIER_CACHE_TTL_SECONDS=604800
PLAYERBOTS_AI_CLASSIFIER_CACHE_MAX_ENTRIES=10000
ACORE_PLAYERBOTS_AI_COMMANDS_ENABLED=1
# 1 = доступно всем; 0 = недоступно никому до реализации подписки.
ACORE_PLAYERBOTS_AI_AVAILABLE_FOR_ALL=1
ACORE_PLAYERBOTS_AI_PLAYER_COOLDOWN_SECONDS=8
ACORE_PLAYERBOTS_AI_MAX_QUEUED_REQUESTS=32
ACORE_PLAYERBOTS_AI_MAX_REPLY_CHARACTERS=240
ACORE_PLAYERBOTS_AI_DEBUG=0
# ------------------------------------------------------------------------------
# Значения по умолчанию для ./create-account.sh
# ------------------------------------------------------------------------------
@@ -393,6 +429,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 +456,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
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
# Solo Random Dungeon client patch
The server recognizes five custom `LFGDungeons.dbc` entries:
| Custom ID | Stock category |
| --- | --- |
| 1258 | Random Classic Dungeon (258) |
| 1259 | Random Burning Crusade Dungeon (259) |
| 1260 | Random Burning Crusade Heroic (260) |
| 1261 | Random Lich King Dungeon (261) |
| 1262 | Random Lich King Heroic (262) |
The generated server DBC is at:
`client_patch/enUS/DBFilesClient/LFGDungeons.dbc`
It is mounted into `ac-worldserver` by `docker-compose.override.yml`.
## Client installation
Use the DBC extracted from the exact locale of the target 3.3.5a client:
```bash
./tools/build-solo-lfg-dbc.py \
/path/to/client/DBFilesClient/LFGDungeons.dbc \
/tmp/solo-rdf/DBFilesClient/LFGDungeons.dbc
```
Put the generated `DBFilesClient/LFGDungeons.dbc` into a client MPQ patch
loaded after the stock locale archives. For a Russian client this is normally
a new archive under `Data/ruRU/` named like `patch-ruRU-S.MPQ`.
After installing the patch, delete the client's `Cache` directory. The
Dungeon Finder list will contain separate solo random categories appropriate
for the character's level.
Do not replace a localized client DBC with the bundled enUS server DBC. Run
the generator against that client's own DBC so all stock localized rows remain
intact.
Binary file not shown.
+10
View File
@@ -19,3 +19,13 @@ AC_CCACHE=true
AC_RESTARTER_BINPATH=
AC_RESTARTER_BINFILE=
AC_RESTARTER_WITHGDB=
#
# mod-encounter-journal
# This deployment reads only .conf.dist files, so module conf overrides
# live here as env vars. ExportOnStart=1 writes JSON/Lua right after the
# custom_ej_* tables load. EXPORT_PATH must point at a directory that's
# mounted on the host so the file appears outside the container.
#
AC_ENCOUNTER_JOURNAL_EXPORT_ON_START=1
AC_ENCOUNTER_JOURNAL_EXPORT_PATH=/azerothcore/env/dist/logs
+169
View File
@@ -0,0 +1,169 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}"
GUILD_NAME=""
ASSUME_YES=0
if [[ -f "$ENV_FILE" ]]; then
set -a
# shellcheck disable=SC1090
source "$ENV_FILE"
set +a
fi
usage() {
cat <<EOF
Usage: $(basename "$0") <guild name> [--yes]
Permanently deletes a guild and its related character-database records.
The worldserver must be stopped before running this script.
Options:
--yes Skip the interactive confirmation
-h, --help Show this help
Examples:
docker compose stop ac-worldserver
./delete-guild.sh "Название гильдии"
./delete-guild.sh "Название гильдии" --yes
docker compose start ac-worldserver
EOF
}
log() {
printf '[delete-guild] %s\n' "$*"
}
while (($#)); do
case "$1" in
--yes)
ASSUME_YES=1
shift
;;
-h|--help)
usage
exit 0
;;
-*)
printf 'Unknown option: %s\n\n' "$1" >&2
usage >&2
exit 1
;;
*)
if [[ -n "$GUILD_NAME" ]]; then
printf 'Only one guild name may be specified\n\n' >&2
usage >&2
exit 1
fi
GUILD_NAME="$1"
shift
;;
esac
done
if [[ -z "$GUILD_NAME" ]]; then
usage >&2
exit 1
fi
if [[ "$GUILD_NAME" == *$'\n'* || "$GUILD_NAME" == *$'\r'* || "$GUILD_NAME" == *$'\t'* ]]; then
printf 'Guild name must not contain control characters\n' >&2
exit 1
fi
cd "$ROOT_DIR"
RUNNING_SERVICES="$(docker compose ps --status running --services)"
if grep -qx 'ac-worldserver' <<<"$RUNNING_SERVICES"; then
printf 'ac-worldserver is running. Stop it first:\n docker compose stop ac-worldserver\n' >&2
exit 1
fi
if ! grep -qx 'ac-database' <<<"$RUNNING_SERVICES"; then
printf 'ac-database is not running. Start it first:\n docker compose up -d ac-database\n' >&2
exit 1
fi
export ACORE_DELETE_GUILD_NAME="$GUILD_NAME"
SQL_GUILD_NAME="$(python3 <<'PY'
import os
value = os.environ["ACORE_DELETE_GUILD_NAME"]
print("'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'")
PY
)"
GUILD_ROW="$(
docker compose exec -T ac-database bash -lc \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --batch --skip-column-names acore_characters' \
<<<"SELECT guildid, name, leaderguid FROM guild WHERE name = ${SQL_GUILD_NAME} LIMIT 1;"
)"
if [[ -z "$GUILD_ROW" ]]; then
printf 'Guild not found: %s\n' "$GUILD_NAME" >&2
exit 1
fi
IFS=$'\t' read -r GUILD_ID FOUND_NAME LEADER_GUID <<<"$GUILD_ROW"
log "found guild id=${GUILD_ID}, name=${FOUND_NAME}, leader=${LEADER_GUID}"
if ((ASSUME_YES == 0)); then
printf 'This permanently deletes the guild and all items in its bank.\n'
printf 'Type the guild name to confirm: '
read -r CONFIRMATION
if [[ "$CONFIRMATION" != "$FOUND_NAME" ]]; then
printf 'Confirmation did not match; nothing was deleted.\n' >&2
exit 1
fi
fi
SQL=$(cat <<EOF
START TRANSACTION;
SET @guild_id := ${GUILD_ID};
CREATE TEMPORARY TABLE delete_guild_members (
guid INT UNSIGNED NOT NULL PRIMARY KEY
) ENGINE=MEMORY
SELECT guid FROM guild_member WHERE guildid = @guild_id;
CREATE TEMPORARY TABLE delete_guild_bank_items (
item_guid INT UNSIGNED NOT NULL PRIMARY KEY
) ENGINE=MEMORY
SELECT item_guid FROM guild_bank_item WHERE guildid = @guild_id;
DELETE item_instance
FROM item_instance
INNER JOIN delete_guild_bank_items
ON delete_guild_bank_items.item_guid = item_instance.guid;
DELETE guild_member_withdraw
FROM guild_member_withdraw
INNER JOIN delete_guild_members
ON delete_guild_members.guid = guild_member_withdraw.guid;
DELETE FROM guild_bank_eventlog WHERE guildid = @guild_id;
DELETE FROM guild_eventlog WHERE guildid = @guild_id;
DELETE FROM guild_bank_right WHERE guildid = @guild_id;
DELETE FROM guild_bank_item WHERE guildid = @guild_id;
DELETE FROM guild_bank_tab WHERE guildid = @guild_id;
DELETE FROM guild_rank WHERE guildid = @guild_id;
DELETE FROM guild_member WHERE guildid = @guild_id;
DELETE FROM guild WHERE guildid = @guild_id;
SET @deleted := ROW_COUNT();
COMMIT;
SELECT @guild_id AS guildid, @deleted AS deleted;
EOF
)
log "deleting guild ${FOUND_NAME} (${GUILD_ID})"
docker compose exec -T ac-database bash -lc \
'mysql -uroot -p"$MYSQL_ROOT_PASSWORD" --show-warnings acore_characters' <<<"$SQL"
log "guild deleted"
File diff suppressed because it is too large Load Diff
+43
View File
@@ -1,4 +1,43 @@
services:
playerbots-ai:
container_name: playerbots-ai
image: moonwell/playerbots-ai:local
build:
context: ./services/playerbots-ai
dockerfile: Dockerfile
networks:
- ac-network
restart: unless-stopped
environment:
PLAYERBOTS_AI_PORT: "8080"
PLAYERBOTS_AI_REQUEST_TIMEOUT: "${PLAYERBOTS_AI_REQUEST_TIMEOUT:-12}"
PLAYERBOTS_AI_TEMPERATURE: "${PLAYERBOTS_AI_TEMPERATURE:-0.4}"
PLAYERBOTS_AI_MAX_TOKENS: "${PLAYERBOTS_AI_MAX_TOKENS:-120}"
PLAYERBOTS_AI_MAX_REPLY_CHARACTERS: "${ACORE_PLAYERBOTS_AI_MAX_REPLY_CHARACTERS:-240}"
PLAYERBOTS_AI_HISTORY_MESSAGES: "${PLAYERBOTS_AI_HISTORY_MESSAGES:-6}"
PLAYERBOTS_AI_COMMANDS_ENABLED: "${ACORE_PLAYERBOTS_AI_COMMANDS_ENABLED:-1}"
PLAYERBOTS_AI_LOG_API_RESPONSES: "${PLAYERBOTS_AI_LOG_API_RESPONSES:-1}"
PLAYERBOTS_AI_CLASSIFIER_CACHE_ENABLED: "${PLAYERBOTS_AI_CLASSIFIER_CACHE_ENABLED:-1}"
PLAYERBOTS_AI_CLASSIFIER_CACHE_DB: "/data/classifier-cache.sqlite3"
PLAYERBOTS_AI_CLASSIFIER_CACHE_TTL_SECONDS: "${PLAYERBOTS_AI_CLASSIFIER_CACHE_TTL_SECONDS:-604800}"
PLAYERBOTS_AI_CLASSIFIER_CACHE_MAX_ENTRIES: "${PLAYERBOTS_AI_CLASSIFIER_CACHE_MAX_ENTRIES:-10000}"
PLAYERBOTS_AI_SYSTEM_PROMPT: "${PLAYERBOTS_AI_SYSTEM_PROMPT:-}"
YANDEX_AI_API_KEY: "${YANDEX_AI_API_KEY:-}"
YANDEX_AI_FOLDER_ID: "${YANDEX_AI_FOLDER_ID:-}"
YANDEX_AI_MODEL_URI: "${YANDEX_AI_MODEL_URI:-}"
YANDEX_AI_BASE_URL: "${YANDEX_AI_BASE_URL:-https://ai.api.cloud.yandex.net/v1}"
volumes:
- playerbots-ai-cache:/data
healthcheck:
test:
- CMD
- python
- -c
- "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health', timeout=2)"
interval: 5s
timeout: 3s
retries: 12
ac-worldserver:
stdin_open: true
tty: true
@@ -8,3 +47,7 @@ services:
volumes:
- ./modules:/azerothcore/modules:ro
- ./lua_scripts:/azerothcore/lua_scripts
- ./client_patch/enUS/DBFilesClient/LFGDungeons.dbc:/azerothcore/env/dist/data/dbc/LFGDungeons.dbc:ro
volumes:
playerbots-ai-cache:
+12
View File
@@ -1,4 +1,16 @@
services:
playerbots-ai:
deploy:
resources:
limits:
cpus: '0.5'
memory: 128M
logging:
driver: "json-file"
options:
max-size: "20m"
max-file: "3"
ac-database:
command: >
--innodb_buffer_pool_size=4G
+100 -1
View File
@@ -14,6 +14,8 @@ 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
@@ -21,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
}
@@ -188,6 +191,28 @@ import_store_sql() {
mysql_exec "" "CREATE DATABASE IF NOT EXISTS \`store\` CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;"
fi
# Unlike the rest of Store.sql, this is a repeatable migration. It ensures
# existing store databases receive the global switch without overwriting a
# value later changed by an administrator.
mysql_exec "store" "
CREATE TABLE IF NOT EXISTS \`store_config\` (
\`id\` TINYINT UNSIGNED NOT NULL,
\`enabled\` TINYINT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (\`id\`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
INSERT IGNORE INTO \`store_config\` (\`id\`, \`enabled\`) VALUES (1, 0);
"
# Store.sql is a one-time bootstrap, not a repeatable migration. It
# contains UPDATE and DELETE statements that would overwrite values
# maintained in the live store database. The canonical services table is
# created by the initial bootstrap, so its presence also protects
# installations created before this guard was introduced.
if database_exists "store" && table_exists "store" "store_services"; then
log "store database already initialized, skipping one-time Store.sql bootstrap"
return 0
fi
temp_sql="$(mktemp)"
{
@@ -234,6 +259,75 @@ 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" "
REPLACE INTO motd (realmid, text)
VALUES (-1, 'Добро пожаловать на сервер MoonWell! Приятной игры!');
DELETE FROM motd_localized WHERE realmid = -1;
"
}
import_encounter_journal_sql() {
local schema_file="$ROOT_DIR/modules/mod-encounter-journal/data/sql/db-world/base/01_encounter_journal_schema.sql"
local sample_file="$ROOT_DIR/modules/mod-encounter-journal/data/sql/db-world/base/02_encounter_journal_sample.sql"
if [[ "$IMPORT_ENCOUNTER_JOURNAL_SQL" == "0" ]]; then
log "Encounter Journal SQL import disabled, skipped"
return 0
fi
if [[ ! -f "$schema_file" ]]; then
log "mod-encounter-journal schema SQL not found, skipped"
return 0
fi
# Schema file uses CREATE TABLE IF NOT EXISTS, but we still gate on the
# canonical tier table to avoid noisy re-imports on every bootstrap.
if ! table_exists "acore_world" "custom_ej_tier"; then
import_sql_file "acore_world" "$schema_file"
else
log "custom_ej_* schema already present, skipping"
fi
# Sample data only on a fresh install (no rows in any custom_ej_* table).
# Re-running it would clobber hand-edited rows in the 9000-9999 id range.
if [[ -f "$sample_file" ]]; then
local total
total="$(mysql_query "acore_world" "
SELECT (SELECT COUNT(*) FROM custom_ej_tier)
+ (SELECT COUNT(*) FROM custom_ej_instance)
+ (SELECT COUNT(*) FROM custom_ej_encounter)
+ (SELECT COUNT(*) FROM custom_ej_creature)
+ (SELECT COUNT(*) FROM custom_ej_section)
+ (SELECT COUNT(*) FROM custom_ej_loot);
")"
if [[ "$total" == "0" ]]; then
import_sql_file "acore_world" "$sample_file"
else
log "custom_ej_* tables already populated, skipping sample data"
fi
fi
}
if (($# > 0)); then
case "$1" in
-h|--help)
@@ -250,6 +344,8 @@ 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"
@@ -260,6 +356,9 @@ fi
import_mythicplus_sql
import_aoe_loot_sql
import_playerbots_ru_sql
import_encounter_journal_sql
import_store_sql
configure_server_motd
log "custom SQL bootstrap completed"
+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."
+60 -13
View File
@@ -5,7 +5,7 @@
local CONFIG = {
maxCategories = 11,
strings = {
categoryAccessDenied = "You do not have access to this category!",
categoryAccessDenied = "У вас нет доступа к этой категории!",
}
}
@@ -64,7 +64,8 @@ local KEYS = {
rewardCount_7 = 27,
rewardCount_8 = 28,
new = 29,
enabled = 30
enabled = 30,
previewDisplayId = 31
},
}
@@ -86,7 +87,14 @@ end
local StoreHandler = AIO.AddHandlers("STORE_CLIENT", {})
function StoreHandler.FrameData(player, services, links, nav, currencies, rank)
function StoreHandler.FrameData(player, storeEnabled, services, links, nav, currencies, rank)
SHOP_UI["Vars"].storeEnabled = storeEnabled == 1
SHOP_UI.StoreAvailability_Update()
if not SHOP_UI["Vars"].storeEnabled then
return
end
SHOP_UI["Data"].services = services
SHOP_UI["Data"].links = links
SHOP_UI["Data"].nav = nav
@@ -112,6 +120,7 @@ SHOP_UI = {
currentPage = 1,
maxPages = 1,
accountRank = 0,
storeEnabled = false,
["playerCurrencies"] = {}
},
["Data"] = {
@@ -144,7 +153,13 @@ function SHOP_UI.MainFrame_Create()
shopFrame.Title:SetFont("Fonts\\FRIZQT__.TTF", 14)
shopFrame.Title:SetShadowOffset(1, -1)
shopFrame.Title:SetPoint("TOP", shopFrame, "TOP", 0, -3)
shopFrame.Title:SetText("|cffedd100Shop|r")
shopFrame.Title:SetText("|cffedd100Лавка Лунной Жрицы|r")
shopFrame.UnavailableText = shopFrame:CreateFontString()
shopFrame.UnavailableText:SetFont("Fonts\\FRIZQT__.TTF", 24, "OUTLINE")
shopFrame.UnavailableText:SetShadowOffset(1, -1)
shopFrame.UnavailableText:SetPoint("CENTER", shopFrame, "CENTER", 85, 0)
shopFrame.UnavailableText:SetText("|cffedd100Извините, магазин ещё не работает|r")
-- create navigation button placeholders, pass parent as arg
SHOP_UI.NavButtons_Create(shopFrame)
@@ -188,6 +203,7 @@ function SHOP_UI.MainFrame_Create()
shopFrame:SetScript(
"OnShow",
function()
AIO.Handle("STORE_SERVER", "FrameData")
AIO.Handle("STORE_SERVER", "UpdateCurrencies")
PlaySound("AuctionWindowOpen", "Master")
end
@@ -206,6 +222,32 @@ function SHOP_UI.MainFrame_Create()
tinsert(UISpecialFrames, shopFrame:GetName())
SHOP_UI["FRAME"] = shopFrame
SHOP_UI.StoreAvailability_Update()
end
function SHOP_UI.StoreAvailability_Update()
local enabled = SHOP_UI["Vars"].storeEnabled
if SHOP_UI["FRAME"] and SHOP_UI["FRAME"].UnavailableText then
if enabled then
SHOP_UI["FRAME"].UnavailableText:Hide()
else
SHOP_UI["FRAME"].UnavailableText:Show()
end
end
for _, collectionName in pairs({"NAV_BUTTONS", "SERVICE_BUTTONS", "PAGING_ELEMENTS", "CURRENCY_BUTTONS"}) do
local collection = SHOP_UI[collectionName]
if collection and not enabled then
for _, element in pairs(collection) do
element:Hide()
end
end
end
if SHOP_UI["MODEL_FRAME"] and not enabled then
SHOP_UI["MODEL_FRAME"]:Hide()
end
end
-- create navigation button placeholders
@@ -333,9 +375,9 @@ function SHOP_UI.OnPurchaseConfirm(data)
end
StaticPopupDialogs["CONFIRM_STORE_PURCHASE"] = {
text = "Are you sure you want to purchase %s?",
button1 = "Yes",
button2 = "No",
text = "Вы уверены, что хотите купить %s?",
button1 = "Да",
button2 = "Нет",
OnAccept = function(self, data)
SHOP_UI.OnPurchaseConfirm(data)
end,
@@ -454,7 +496,7 @@ function SHOP_UI.ServiceBoxes_Create(parent)
service.buyButton.ButtonText = service.buyButton:CreateFontString()
service.buyButton.ButtonText:SetFont("Fonts\\FRIZQT__.TTF", 11, "OUTLINE")
service.buyButton.ButtonText:SetPoint("CENTER", service.buyButton, 0, 0)
service.buyButton.ButtonText:SetText("Buy now!")
service.buyButton.ButtonText:SetText("Купить")
service.buyButton:SetScript(
"OnClick",
@@ -503,7 +545,7 @@ function SHOP_UI.ServiceBoxes_Create(parent)
if(self.Type == 1 and service.Flags == 1) then
SHOP_UI.ModelFrame_ShowPlayer(self.Rewards)
elseif((self.Type == 3 or self.Type == 4) and self.DisplayId > 0) then -- Handler for creatures with models
SHOP_UI.ModelFrame_ShowCreature(self.DisplayId)
SHOP_UI.ModelFrame_ShowCreature(self.DisplayId, self.PreviewDisplayId)
end
-- Parchment page sound
PlaySound(836)
@@ -614,6 +656,7 @@ function SHOP_UI.ServiceBoxes_Update()
service.Currency = serviceData[KEYS.service.currency]
service.TooltipHyperlink = serviceData[KEYS.service.hyperlink]
service.DisplayId = serviceData[KEYS.service.displayId]
service.PreviewDisplayId = serviceData[KEYS.service.previewDisplayId] or 0
service.Discount = serviceData[KEYS.service.discount]
service.Flags = serviceData[KEYS.service.flags]
service.New = serviceData[KEYS.service.new]
@@ -635,7 +678,7 @@ function SHOP_UI.ServiceBoxes_Update()
-- calculate discount percentage
local discountPct = math.floor(((service.Price - service.Discount) - service.Price) / service.Price * 100)
service.BannerText:SetFormattedText("|cffffffffOn sale: %i%%|r", discountPct)
service.BannerText:SetFormattedText("|cffffffffСкидка: %i%%|r", discountPct)
-- if service is discounted, then show all the discount frames and override the price text. otherwise hide.
if service.Discount > 1 then
@@ -1150,7 +1193,7 @@ function SHOP_UI.ModelFrame_ShowPlayer(displayId)
PlaySound("INTERFACESOUND_GAMESCROLLBUTTON", "Master")
end
function SHOP_UI.ModelFrame_ShowCreature(displayId)
function SHOP_UI.ModelFrame_ShowCreature(creatureEntry, previewDisplayId)
-- hacky ass model frame handling
-- hide model frames
if(SHOP_UI["MODEL_FRAME"].playerModel:IsShown()) then
@@ -1161,7 +1204,11 @@ function SHOP_UI.ModelFrame_ShowCreature(displayId)
SHOP_UI["MODEL_FRAME"]:Hide()
-- set the correct unit and show frame
SHOP_UI["MODEL_FRAME"].creatureModel:SetCreature(displayId)
if(previewDisplayId and previewDisplayId > 0) then
SHOP_UI["MODEL_FRAME"].creatureModel:SetCreature(creatureEntry, previewDisplayId)
else
SHOP_UI["MODEL_FRAME"].creatureModel:SetCreature(creatureEntry)
end
SHOP_UI["MODEL_FRAME"].creatureModel:Show()
SHOP_UI["MODEL_FRAME"]:Show()
@@ -1193,7 +1240,7 @@ local function ModifyGameMenuFrame()
storeButton.Text:SetFont("Fonts\\FRIZQT__.TTF", 13, "OUTLINE")
storeButton.Text:SetShadowOffset(1, -1)
storeButton.Text:SetPoint("CENTER", storeButton, "CENTER", 0, 1)
storeButton.Text:SetText("|cffdbe005Store");
storeButton.Text:SetText("|cffdbe005Лавка Лунной Жрицы");
-- on click open the shop frame and hide the escape menu
storeButton:SetScript("OnClick", function()
+25 -1
View File
@@ -59,7 +59,8 @@ local KEYS = {
rewardCount_7 = 27,
rewardCount_8 = 28,
new = 29,
enabled = 30
enabled = 30,
previewDisplayId = 31
},
}
@@ -139,6 +140,8 @@ function ServiceData.Load()
Query:GetUInt32(KEYS.service.rewardCount_7),
Query:GetUInt32(KEYS.service.rewardCount_8),
Query:GetUInt32(KEYS.service.new),
Query:GetUInt32(KEYS.service.enabled),
0,
}
end
until not Query:NextRow()
@@ -224,6 +227,27 @@ function CreatureDisplays.Load()
until not ModelQuery:NextRow()
end
end
-- Keep the creature entry for the stock preview path, but also send its
-- primary CreatureDisplayInfo ID. WarcraftXL can apply that display
-- directly, avoiding stale or missing client creature-cache records.
for _, service in pairs(ServiceData.Cache) do
local serviceType = service[KEYS.service.serviceType]
local entry = service[KEYS.service.displayOrEntry]
if ((serviceType == 3 or serviceType == 4) and entry > 0) then
local creature = CreatureDisplays.Cache[entry]
local displayId = 0
if (creature) then
for modelIndex = 11, 14 do
if (creature[modelIndex] and creature[modelIndex] > 0) then
displayId = creature[modelIndex]
break
end
end
end
service[KEYS.service.previewDisplayId] = displayId
end
end
end
function LinkData.Load()
+96 -63
View File
@@ -7,13 +7,16 @@ local CONFIG = {
mailSenderGUID = 1, -- GUID of the character shown as sender of purchase mails
strings = {
-- Currency name is appended to the end of this string
insufficientFunds = "You don't have enough",
insufficientFunds = "Недостаточно средств на балансе",
-- Type is appended, ie. title, mount, pet etc.
alreadyKnown = "You already have this",
tooHighLevel = "Your level is too high",
mailBody = "Thank you for your purchase!",
alreadyKnownMount = "Этот транспорт уже изучен",
alreadyKnownPet = "Этот питомец уже изучен",
alreadyKnownTitle = "Этот титул уже получен",
tooHighLevel = "Уровень персонажа слишком высок",
mailBody = "Благодарим за покупку в Лавке Лунной Жрицы!",
-- The service name is prefixed to this message
successfulPurchase = "successfully purchased!"
successfulPurchase = "Покупка успешно совершена!",
storeUnavailable = "Магазин пока не работает"
}
}
@@ -22,11 +25,24 @@ local CONFIG = {
local AIO = AIO or require("AIO") and require("Store_DataStruct")
local CURRENCY_TYPES = {
[1] = "GOLD",
[2] = "ITEM_TOKEN",
[3] = "SERVER_HANDLED"
}
local purchaseReferenceSequence = 0
local function GetAccountBalance(player)
local query = AuthDBQuery(
"SELECT CAST(`balance` AS DOUBLE) FROM `account_balances` WHERE `account_id` = " ..
player:GetAccountId() .. " LIMIT 1;"
)
if not query then
return 0
end
return query:GetDouble(0)
end
local SHOP_UI = {
serviceHandlers = {
[1] = "ItemHandler", -- Okay
@@ -45,27 +61,38 @@ local KEYS = GetDataStructKeys();
local StoreHandler = AIO.AddHandlers("STORE_SERVER", {})
local function IsStoreEnabled()
local query = WorldDBQuery("SELECT `enabled` FROM `store`.`store_config` WHERE `id` = 1 LIMIT 1;")
return query and query:GetUInt32(0) == 1
end
function StoreHandler.FrameData(player)
AIO.Handle(player, "STORE_CLIENT", "FrameData", GetServiceData(), GetLinkData(), GetNavData(), GetCurrencyData(), player:GetGMRank())
local enabled = IsStoreEnabled()
AIO.Handle(
player,
"STORE_CLIENT",
"FrameData",
enabled and 1 or 0,
enabled and GetServiceData() or {},
enabled and GetLinkData() or {},
enabled and GetNavData() or {},
enabled and GetCurrencyData() or {},
player:GetGMRank()
)
end
function StoreHandler.UpdateCurrencies(player)
if not IsStoreEnabled() then
return
end
local tmp = {}
for currencyId, currency in pairs(GetCurrencyData()) do
local val = 0
local currencyTypeText = CURRENCY_TYPES[currency[KEYS.currency.currencyType]]
-- Handle the different currency types
if(currencyTypeText == "GOLD") then
val = math.floor(player:GetCoinage() / 10000)
end
if(currencyTypeText == "ITEM_TOKEN") then
val = player:GetItemCount(currency[KEYS.currency.data])
end
if(currencyTypeText == "SERVER_HANDLED") then
-- Add your custom handling here for retreiving your server handled currencies
val = GetAccountBalance(player)
end
-- If value is larger than 10k then truncate to make sure it fits within the shop frame
@@ -73,12 +100,19 @@ function StoreHandler.UpdateCurrencies(player)
val = "9999+"
end
table.insert(tmp, val)
-- Preserve the database currency id. A positional array silently breaks as
-- soon as ids have gaps or are reordered by MySQL.
tmp[currencyId] = val
end
AIO.Handle(player, "STORE_CLIENT", "UpdateCurrencies", tmp)
end
function StoreHandler.Purchase(player, serviceId)
if not IsStoreEnabled() then
player:SendAreaTriggerMessage(CONFIG.strings.storeUnavailable)
return
end
local services = GetServiceData()
-- See if the requested service exists
@@ -99,46 +133,45 @@ function StoreHandler.Purchase(player, serviceId)
player:PlayDirectSound(120, player)
-- Send success toast
player:SendAreaTriggerMessage(services[serviceId][KEYS.service.name] .. " "..CONFIG.strings.successfulPurchase)
player:SendAreaTriggerMessage(CONFIG.strings.successfulPurchase)
end
end
end
end
-- Helper functions
function SHOP_UI.DeductCurrency(player, currencyId, amount)
function SHOP_UI.DeductCurrency(player, currencyId, amount, serviceId)
local currency = GetCurrencyData()
local currencyType = currency[currencyId][KEYS.currency.currencyType]
local currencyName = currency[currencyId][KEYS.currency.name]
local currencyData = currency[currencyId][KEYS.currency.data]
-- Gold handling
if(CURRENCY_TYPES[currencyType] == "GOLD") then
if(player:GetCoinage() < amount * 10000) then
player:SendAreaTriggerMessage("|cFFFF0000"..CONFIG.strings.insufficientFunds.." "..currencyName.."|r")
player:PlayDirectSound(GetSoundEffect("notEnoughMoney", player:GetRace(), player:GetGender()), player)
return false
end
player:SetCoinage(player:GetCoinage() - (amount * 10000))
end
-- Token handling
if(CURRENCY_TYPES[currencyType] == "ITEM_TOKEN") then
if not(player:HasItem(currencyData, amount)) then
player:SendAreaTriggerMessage("|cFFFF0000"..CONFIG.strings.insufficientFunds.." "..currencyName.."|r")
player:PlayDirectSound(GetSoundEffect("notEnoughMoney", player:GetRace(), player:GetGender()), player)
return false
end
player:RemoveItem(currencyData, amount)
end
-- Other special handlingm you have to add your own integration here.
if(CURRENCY_TYPES[currencyType] == "SERVER_HANDLED") then
if not currency[currencyId] then
return false
end
local currencyType = currency[currencyId][KEYS.currency.currencyType]
if CURRENCY_TYPES[currencyType] ~= "SERVER_HANDLED" then
return false
end
purchaseReferenceSequence = purchaseReferenceSequence + 1
local reference = string.format(
"game-store:%u:%u:%u:%u:%u",
player:GetAccountId(),
player:GetGUIDLow(),
serviceId or 0,
os.time(),
purchaseReferenceSequence
)
local result = AuthDBQuery(
"SELECT `store_deduct_balance`(" .. player:GetAccountId() .. ", " .. amount .. ", '" .. reference .. "', " ..
player:GetGUIDLow() .. ", " .. (serviceId or 0) .. ") AS `balance_after`;"
)
local balanceAfter = result and result:GetDouble(0) or -1
if balanceAfter < 0 then
player:SendAreaTriggerMessage("|cFFFF0000" .. CONFIG.strings.insufficientFunds .. "|r")
player:PlayDirectSound(GetSoundEffect("notEnoughMoney", player:GetRace(), player:GetGender()), player)
return false
end
return true
end
@@ -152,7 +185,7 @@ function SHOP_UI.ItemHandler(player, data)
local currency, amount = data[KEYS.service.currency], data[KEYS.service.price] - data[KEYS.service.discount]
-- Deduct currency
local deducted = SHOP_UI.DeductCurrency(player, currency, amount)
local deducted = SHOP_UI.DeductCurrency(player, currency, amount, data.ID)
-- If currency was not deducted from the player, abort and send message
if not(deducted) then
@@ -169,7 +202,7 @@ function SHOP_UI.ItemHandler(player, data)
end
-- Send reward mail
SendMail("Purchase of: "..data[KEYS.service.name], CONFIG.strings.mailBody, player:GetGUIDLow(), CONFIG.mailSenderGUID, 62, 0, 0, 0, unpack(items))
SendMail("Покупка: "..data[KEYS.service.name], CONFIG.strings.mailBody, player:GetGUIDLow(), CONFIG.mailSenderGUID, 62, 0, 0, 0, unpack(items))
return true
end
@@ -178,7 +211,7 @@ function SHOP_UI.GoldHandler(player, data)
local currency, amount = data[KEYS.service.currency], data[KEYS.service.price] - data[KEYS.service.discount]
-- Deduct currency
local deducted = SHOP_UI.DeductCurrency(player, currency, amount)
local deducted = SHOP_UI.DeductCurrency(player, currency, amount, data.ID)
-- If currency was not deducted from the player, abort and send message
if not(deducted) then
@@ -206,13 +239,13 @@ function SHOP_UI.MountHandler(player, data)
-- check if player already has the spells learned
if(knownCount == rewardCount) then
player:SendAreaTriggerMessage("|cFFFF0000"..CONFIG.strings.alreadyKnown.." mount|r")
player:SendAreaTriggerMessage("|cFFFF0000"..CONFIG.strings.alreadyKnownMount.."|r")
player:PlayDirectSound(GetSoundEffect("cantLearn", player:GetRace(), player:GetGender()), player)
return false
end
-- Deduct currency
local deducted = SHOP_UI.DeductCurrency(player, currency, amount)
local deducted = SHOP_UI.DeductCurrency(player, currency, amount, data.ID)
-- If currency was not deducted from the player, abort and send message
if not(deducted) then
@@ -244,13 +277,13 @@ function SHOP_UI.PetHandler(player, data)
-- check if player already has the spells learned
if(knownCount == rewardCount) then
player:SendAreaTriggerMessage("|cFFFF0000"..CONFIG.strings.alreadyKnown.." pet|r")
player:SendAreaTriggerMessage("|cFFFF0000"..CONFIG.strings.alreadyKnownPet.."|r")
player:PlayDirectSound(GetSoundEffect("cantLearn", player:GetRace(), player:GetGender()), player)
return false
end
-- Deduct currency
local deducted = SHOP_UI.DeductCurrency(player, currency, amount)
local deducted = SHOP_UI.DeductCurrency(player, currency, amount, data.ID)
-- If currency was not deducted from the player, abort and send message
if not(deducted) then
@@ -271,7 +304,7 @@ function SHOP_UI.BuffHandler(player, data)
local currency, amount = data[KEYS.service.currency], data[KEYS.service.price] - data[KEYS.service.discount]
-- Deduct currency
local deducted = SHOP_UI.DeductCurrency(player, currency, amount)
local deducted = SHOP_UI.DeductCurrency(player, currency, amount, data.ID)
-- If currency was not deducted from the player, abort and send message
if not(deducted) then
@@ -292,7 +325,7 @@ function SHOP_UI.ServiceHandler(player, data)
local currency, amount = data[KEYS.service.currency], data[KEYS.service.price] - data[KEYS.service.discount]
-- Deduct currency
local deducted = SHOP_UI.DeductCurrency(player, currency, amount)
local deducted = SHOP_UI.DeductCurrency(player, currency, amount, data.ID)
-- If currency was not deducted from the player, abort and send message
if not(deducted) then
@@ -319,7 +352,7 @@ function SHOP_UI.LevelHandler(player, data)
end
-- Deduct currency
local deducted = SHOP_UI.DeductCurrency(player, currency, amount)
local deducted = SHOP_UI.DeductCurrency(player, currency, amount, data.ID)
-- If currency was not deducted from the player, abort and send message
if not(deducted) then
@@ -348,13 +381,13 @@ function SHOP_UI.TitleHandler(player, data)
-- Check whether or not the player already has the specified title
if(player:HasTitle(data[KEYS.service.reward_1])) then
player:SendAreaTriggerMessage("|cFFFF0000"..CONFIG.strings.alreadyKnown.." title|r")
player:SendAreaTriggerMessage("|cFFFF0000"..CONFIG.strings.alreadyKnownTitle.."|r")
player:PlayDirectSound(GetSoundEffect("cantLearn", player:GetRace(), player:GetGender()), player)
return false
end
-- Deduct currency
local deducted = SHOP_UI.DeductCurrency(player, currency, amount)
local deducted = SHOP_UI.DeductCurrency(player, currency, amount, data.ID)
-- If currency was not deducted from the player, abort and send message
if not(deducted) then
@@ -372,4 +405,4 @@ function SHOP_UI.UnusedHandler(player, data)
-- Since this is unused, always return false until the function is in use.
return false
end
end
+2 -7
View File
@@ -1,7 +1,2 @@
local PLAYER_EVENT_ON_LOGIN = 3
local function OnLogin(event, player)
player:SendBroadcastMessage("Eluna greetings you")
end
RegisterPlayerEvent(PLAYER_EVENT_ON_LOGIN, OnLogin)
-- Login greeting is managed by the native AzerothCore MOTD in
-- import-custom-sql.sh. Keep this file as a harmless Eluna loading example.
+7 -86
View File
@@ -1,86 +1,7 @@
local AIO = AIO or require("AIO")
local ADDON_NAME = "MoonWellClient"
local HANDLER_NAME = "Init"
local GAME_MODE_NORMAL = 0
local GAME_MODE_TRAITOR = 1
local PLAYER_EVENT_ON_LOGIN = 3
local AIO_INIT_SEND_DELAY_MS = 1000
local MoonWellAIO = rawget(_G, "MoonWellAIO") or {}
_G.MoonWellAIO = MoonWellAIO
local PendingSyncEvents = {}
local function GetPlayerGameMode(player)
if not player then
return GAME_MODE_NORMAL
end
local query = CharDBQuery(
"SELECT game_mode FROM mod_gamemodes_characters WHERE guid = " .. player:GetGUIDLow() .. " LIMIT 1"
)
if not query or query:IsNull(0) then
return GAME_MODE_NORMAL
end
local gameMode = query:GetUInt8(0)
if gameMode == GAME_MODE_TRAITOR then
return GAME_MODE_TRAITOR
end
return GAME_MODE_NORMAL
end
function MoonWellAIO.GetPlayerGameMode(player)
return GetPlayerGameMode(player)
end
function MoonWellAIO.SendPlayerGameMode(player)
if not player then
return GAME_MODE_NORMAL
end
local gameMode = GetPlayerGameMode(player)
AIO.Handle(player, ADDON_NAME, HANDLER_NAME, gameMode)
return gameMode
end
local function CancelPendingSync(playerGuidLow)
local eventId = PendingSyncEvents[playerGuidLow]
if eventId then
RemoveEventById(eventId)
PendingSyncEvents[playerGuidLow] = nil
end
end
function MoonWellAIO.SchedulePlayerGameMode(player, delayMs)
if not player then
return
end
local playerGuidLow = player:GetGUIDLow()
local playerGuid = player:GetGUID()
CancelPendingSync(playerGuidLow)
PendingSyncEvents[playerGuidLow] = CreateLuaEvent(function()
PendingSyncEvents[playerGuidLow] = nil
local onlinePlayer = GetPlayerByGUID(playerGuid)
if onlinePlayer then
MoonWellAIO.SendPlayerGameMode(onlinePlayer)
end
end, delayMs or AIO_INIT_SEND_DELAY_MS, 1)
end
-- Do not append MoonWellClient blocks directly into the AIO init packet:
-- the client addon may register its handlers slightly later than AIO itself.
AIO.AddOnInit(function(msg, player)
MoonWellAIO.SchedulePlayerGameMode(player, AIO_INIT_SEND_DELAY_MS)
return msg
end)
RegisterPlayerEvent(PLAYER_EVENT_ON_LOGIN, function(_, player)
MoonWellAIO.SchedulePlayerGameMode(player, AIO_INIT_SEND_DELAY_MS)
end)
-- Game-mode UI synchronization is client-native since MoonWellClient 2.0.
--
-- The authoritative mode still comes from the server in SMSG_CHAR_ENUM as
-- CHARACTER_FLAG_UNK31 (0x40000000). WarcraftXL retains that bit while WoW
-- switches from GlueXML to the in-world FrameXML Lua state. Keeping this file
-- as a no-op avoids stale deployment scripts failing on a missing path while
-- removing the timing-sensitive AIO login event completely.
+5
View File
@@ -0,0 +1,5 @@
This module is part of the AzerothCore project and is distributed under the
terms of the GNU General Public License v2 (GPLv2). See the AzerothCore LICENSE
file for the full license text:
https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE
+195
View File
@@ -0,0 +1,195 @@
# mod-encounter-journal
Source-of-truth for the **MoonWellClient EncounterJournal** addon. Stores
all data (tiers, instances, encounters, sections, creatures, loot) in
`acore_world` under `custom_ej_*` tables and exports them as JSON / Lua
that the client can consume directly. No DBC dependency.
## Layout
```text
mod-encounter-journal/
├── conf/ module configuration
├── data/sql/db-world/base/
│ ├── 01_encounter_journal_schema.sql table definitions
│ └── 02_encounter_journal_sample.sql sample data (Deadmines, Naxxramas)
├── data/sql/db-world/updates/ future incremental migrations
├── docs/encounter_journal_format.md output structure reference
├── src/ C++ implementation (manager + commands)
└── tools/json_to_lua.py offline JSON → Lua fallback
```
## Tables
| Table | Purpose |
| --------------------------- | ---------------------------------------------------------- |
| `custom_ej_tier` | Top-level expansion / content bucket |
| `custom_ej_instance` | Dungeon / raid header |
| `custom_ej_tier_instance` | N:M tier ↔ instance |
| `custom_ej_encounter` | Boss / encounter under an instance |
| `custom_ej_creature` | Creature card (portrait, name, description) per encounter |
| `custom_ej_section` | Tree of Overview / Abilities sections |
| `custom_ej_loot` | Explicit loot table per encounter |
References to standard AzerothCore tables:
- `custom_ej_instance.map_id``instance_template.map`
- `custom_ej_creature.creature_id``creature_template.entry`
- `custom_ej_loot.item_id``item_template.entry`
- `custom_ej_creature.creature_display_id` is optional and only used by the
client's 3D portrait tab; pull it from
`creature_template.modelid1..modelid4` when populating manually.
## Bitmasks
### `instance.flags`
| Bit | Meaning |
| ------- | ----------------- |
| `0x000` | dungeon (default) |
| `0x010` | raid |
| `0x040` | hide difficulty |
| `0x800` | open world |
### `encounter.difficulty_mask`
For dungeons:
| Bit | Meaning |
| ----- | --------------- |
| `0x1` | normal |
| `0x2` | heroic |
| `0x4` | mythic (custom) |
For raids:
| Bit | Meaning |
| ----- | --------- |
| `0x1` | 10 normal |
| `0x2` | 25 normal |
| `0x4` | 10 heroic |
| `0x8` | 25 heroic |
### `section.flags`
| Bit | Meaning |
| ------ | ------------ |
| `0x1` | starts open |
| `0x2` | heroic only |
## Console / GM commands
All commands require **administrator** rights, except `info` which is
gamemaster-only. They run from the in-game chat **and** the worldserver
console.
| Command | What it does |
| ---------------- | ---------------------------------------------------------------------------- |
| Command | What it does |
| -------------------- | --------------------------------------------------------------------------------------------------- |
| `.ej info` | Print row counts of the in-memory cache. |
| `.ej reload` | Re-read all `custom_ej_*` tables. |
| `.ej validate` | Run integrity checks (see "Validation" below). |
| `.ej importskeleton` | Generate instance/encounter/creature/section rows from `instance_template` + `instance_encounters`. |
| `.ej importloot` | Auto-populate `custom_ej_loot` from `creature_loot_template` (skips dupes). |
| `.ej importall` | Run `importskeleton` then `importloot` back-to-back. |
| `.ej export` | Write `EncounterJournalData.json` (and `.lua`) to the configured path. |
## Configuration
`conf/mod_encounter_journal.conf.dist` (rename to `.conf` to override):
| Option | Default | Description |
| ----------------------------------- | ------------------------------ | --------------------------------------------------------------------------------------------- |
| `EncounterJournal.Enable` | `1` | Master switch. |
| `EncounterJournal.Export.Path` | (current working dir) | Output directory; must exist & be writable. |
| `EncounterJournal.Export.JsonFile` | `EncounterJournalData.json` | JSON filename. |
| `EncounterJournal.Export.LuaFile` | `EncounterJournalData.lua` | Lua filename. Empty string skips Lua generation. |
| `EncounterJournal.Export.LuaGlobal` | `MoonWellEncounterJournalData` | Name of the Lua global the addon reads. |
| `EncounterJournal.ExportOnStart` | `0` | When `1`, exports JSON/Lua right after startup load (useful for headless docker without TTY). |
## Output format
The exporter writes the following top-level structure (full per-row layout
in [`docs/encounter_journal_format.md`](docs/encounter_journal_format.md)):
```jsonc
{
"tiers": [ /* [id, name, flags, sort_order] */ ],
"instances": { /* "instance_id": [...] */ },
"tierInstances": { /* "tier_id": [instance_id, ...] */ },
"encounters": { /* "instance_id": [[...], [...]] */ },
"creatures": { /* "encounter_id": [[...], [...]] */ },
"sections": { /* "section_id": [...] */ },
"items": { /* "encounter_id": [[...], [...]] */ }
}
```
Field order inside each row matches the layout the MoonWellClient
EncounterJournal addon expects, so the Lua file can be loaded directly:
```lua
-- in your addon Init() or once on load
local data = MoonWellEncounterJournalData
for _, tier in ipairs(data.tiers) do
-- tier[1]=id, tier[2]=name, tier[3]=flags, tier[4]=sort_order
end
```
## Workflow
1. **Edit data** — INSERT/UPDATE rows in `custom_ej_*` tables.
2. **Validate**`.ej validate` (no errors expected).
3. **Export**`.ej export` (writes JSON + Lua to `Export.Path`).
4. **Ship** — copy `EncounterJournalData.lua` into the
MoonWellClient EncounterJournal addon directory.
For loot, you can shortcut step 1:
1. Insert the encounter and a `custom_ej_creature` row with `creature_id`
set to the boss NPC id.
2. Run `.ej importloot` — entries from `creature_loot_template` (and one
level of `reference_loot_template`) are inserted into `custom_ej_loot`
with `difficulty_mask` copied from the creature card. Existing rows are
skipped via the unique key.
3. Manually clean up: drop trash items, set `faction_mask` /
`class_mask` / `sort_order` where needed.
4. Re-run `.ej validate` and `.ej export`.
## Validation
`.ej validate` verifies:
- every `tier_instance.tier_id` and `tier_instance.instance_id` exists;
- every `encounter.instance_id` exists;
- every `encounter.first_section_id` exists or equals `0`;
- `encounter.difficulty_mask` only uses bits valid for the parent
instance type (dungeon → `0x7`, raid → `0xF`);
- every `creature.encounter_id` exists;
- every `creature.creature_id` (if non-zero) exists in `creature_template`;
- every `section.encounter_id` exists;
- every `section.parent_section_id` / `next_section_id` /
`sub_section_id` (if non-zero) exists;
- every `loot.encounter_id` exists;
- every `loot.item_id` exists in `item_template`;
- no `loot` duplicates by
`(encounter_id, item_id, difficulty_mask, faction_mask, class_mask)`.
`sort_order` stability is enforced by the schema (column has no
`AUTO_INCREMENT`/random default, exporter sorts on it).
## Offline JSON → Lua
If you produce the JSON outside the worldserver (e.g., from a CI job that
talks to MySQL directly), use the bundled converter:
```sh
python3 modules/mod-encounter-journal/tools/json_to_lua.py \
--input EncounterJournalData.json \
--output EncounterJournalData.lua \
--global MoonWellEncounterJournalData
```
The output uses the same field order, key sort, and global name as
`.ej export`, so the addon does not care which path produced the file.
Whitespace / float formatting may differ slightly.
@@ -0,0 +1,83 @@
#
# This file is part of the AzerothCore project.
#
########################################
# mod-encounter-journal configuration
########################################
#
# EncounterJournal.Enable
# Description: Master switch for the module. When 0 the module
# does not register the .ej commands.
# Default: 1
#
EncounterJournal.Enable = 1
#
# EncounterJournal.Export.Path
# Description: Directory where exported files are written. The
# directory must already exist and be writable by the
# worldserver process. Trailing slash is optional.
# Empty string == current working directory.
# Tip (Docker): set to "/azerothcore/env/dist/logs"
# so files appear on the host under ./env/dist/logs.
# Default: ""
#
EncounterJournal.Export.Path = "/env/dist/logs"
#
# EncounterJournal.Export.JsonFile
# Description: Filename of the JSON export written under Export.Path.
# Default: "EncounterJournalData.json"
#
EncounterJournal.Export.JsonFile = "EncounterJournalData.json"
#
# EncounterJournal.Export.LuaFile
# Description: Filename of the Lua export written under Export.Path.
# Set to empty string to skip Lua generation.
# Default: "EncounterJournalData.lua"
#
EncounterJournal.Export.LuaFile = "EncounterJournalData.lua"
#
# EncounterJournal.Export.LuaGlobal
# Description: Name of the Lua global the .lua file assigns its
# payload to. The MoonWellClient EncounterJournal addon
# reads this global on load.
# Default: "MoonWellEncounterJournalData"
#
EncounterJournal.Export.LuaGlobal = "MoonWellEncounterJournalData"
#
# EncounterJournal.ExportOnStart
# Description: When 1, the worldserver writes the JSON/Lua files to
# EncounterJournal.Export.Path immediately after the
# custom_ej_* tables are loaded at startup. Useful when
# the in-game/CLI .ej commands are not available
# (CI builds, headless docker without an attached TTY).
# Default: 0
#
EncounterJournal.ExportOnStart = 1
#
# EncounterJournal.ImportOnStart
# Description: When 1, on startup the worldserver auto-populates
# custom_ej_* tables from instance_template,
# instance_encounters and creature_template, then runs
# the loot importer. Existing rows are kept (INSERT
# IGNORE on PK), so manual edits are preserved.
# Use this once after a fresh install or after wiping
# the auto-imported rows; descriptions stay empty and
# must be filled by hand.
# Default: 0
#
EncounterJournal.ImportOnStart = 0
@@ -0,0 +1,115 @@
--
-- Encounter Journal: schema for custom_ej_* tables.
-- Source of truth for client EncounterJournal data lives here, not in DBC.
--
-- Tables are CREATE TABLE IF NOT EXISTS so re-running the file is safe.
-- DBUpdater re-applies the file when its hash changes.
--
CREATE TABLE IF NOT EXISTS `custom_ej_tier` (
`id` INT UNSIGNED NOT NULL,
`name` VARCHAR(255) NOT NULL,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: tier (expansion/content bucket).';
CREATE TABLE IF NOT EXISTS `custom_ej_instance` (
`id` INT UNSIGNED NOT NULL,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`button_icon` VARCHAR(255) NOT NULL DEFAULT '',
`small_button_icon` VARCHAR(255) NOT NULL DEFAULT '',
`background` VARCHAR(255) NOT NULL DEFAULT '',
`lore_background` VARCHAR(255) NOT NULL DEFAULT '',
`map_id` INT UNSIGNED NOT NULL DEFAULT 0,
`area_id` INT UNSIGNED NOT NULL DEFAULT 0,
`world_map_area_id` INT UNSIGNED NOT NULL DEFAULT 0,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: dungeon/raid/scenario.';
CREATE TABLE IF NOT EXISTS `custom_ej_tier_instance` (
`tier_id` INT UNSIGNED NOT NULL,
`instance_id` INT UNSIGNED NOT NULL,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`tier_id`, `instance_id`),
KEY `idx_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: tier <-> instance N:M.';
CREATE TABLE IF NOT EXISTS `custom_ej_encounter` (
`id` INT UNSIGNED NOT NULL,
`instance_id` INT UNSIGNED NOT NULL,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`map_position_x` FLOAT NOT NULL DEFAULT 0,
`map_position_y` FLOAT NOT NULL DEFAULT 0,
`floor_index` INT UNSIGNED NOT NULL DEFAULT 1,
`world_map_area_id` INT UNSIGNED NOT NULL DEFAULT 0,
`first_section_id` INT UNSIGNED NOT NULL DEFAULT 0,
`difficulty_mask` INT UNSIGNED NOT NULL DEFAULT 0,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_instance` (`instance_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: boss / encounter.';
CREATE TABLE IF NOT EXISTS `custom_ej_creature` (
`id` INT UNSIGNED NOT NULL,
`encounter_id` INT UNSIGNED NOT NULL,
`creature_id` INT UNSIGNED NOT NULL DEFAULT 0,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`creature_display_id` INT UNSIGNED NOT NULL DEFAULT 0,
`icon` VARCHAR(255) NOT NULL DEFAULT '',
`difficulty_mask` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_encounter` (`encounter_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: creature card under an encounter.';
CREATE TABLE IF NOT EXISTS `custom_ej_section` (
`id` INT UNSIGNED NOT NULL,
`encounter_id` INT UNSIGNED NOT NULL,
`parent_section_id` INT UNSIGNED NOT NULL DEFAULT 0,
`next_section_id` INT UNSIGNED NOT NULL DEFAULT 0,
`sub_section_id` INT UNSIGNED NOT NULL DEFAULT 0,
`name` VARCHAR(255) NOT NULL,
`description` TEXT NOT NULL,
`creature_display_id` INT UNSIGNED NOT NULL DEFAULT 0,
`description_spell_id` INT UNSIGNED NOT NULL DEFAULT 0,
`icon_spell_id` INT UNSIGNED NOT NULL DEFAULT 0,
`icon` VARCHAR(255) NULL,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`icon_flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
`type` INT UNSIGNED NOT NULL DEFAULT 0,
`difficulty_mask` INT UNSIGNED NOT NULL DEFAULT 0,
`creature_id` INT UNSIGNED NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_encounter` (`encounter_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: encounter section (Overview/Abilities tree).';
CREATE TABLE IF NOT EXISTS `custom_ej_loot` (
`id` INT UNSIGNED NOT NULL AUTO_INCREMENT,
`encounter_id` INT UNSIGNED NOT NULL,
`item_id` INT UNSIGNED NOT NULL,
`difficulty_mask` INT UNSIGNED NOT NULL DEFAULT 0,
`faction_mask` INT NOT NULL DEFAULT -1,
`class_mask` INT NOT NULL DEFAULT -1,
`flags` INT UNSIGNED NOT NULL DEFAULT 0,
`sort_order` INT NOT NULL DEFAULT 0,
PRIMARY KEY (`id`),
KEY `idx_encounter` (`encounter_id`),
KEY `idx_item` (`item_id`),
UNIQUE KEY `uk_loot_dedup`
(`encounter_id`, `item_id`, `difficulty_mask`, `faction_mask`, `class_mask`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
COMMENT='Encounter Journal: explicit loot table per encounter.';
@@ -0,0 +1,179 @@
--
-- Encounter Journal: minimal sample data.
-- Showcases one dungeon (Deadmines, map 36) and one raid (Naxxramas, map 533),
-- with one encounter each, a creature card, a section tree (overview + 1 ability)
-- and a couple of loot rows. Loot item_ids reference real WotLK item_template entries.
--
-- IDs used by the sample stay above 9000 to avoid collisions with future imports.
--
START TRANSACTION;
DELETE FROM `custom_ej_loot` WHERE `encounter_id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_section` WHERE `encounter_id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_creature` WHERE `encounter_id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_encounter` WHERE `id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_tier_instance` WHERE `instance_id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_instance` WHERE `id` BETWEEN 9000 AND 9999;
DELETE FROM `custom_ej_tier` WHERE `id` BETWEEN 9000 AND 9999;
-- ---------------------------------------------------------------------------
-- Tiers
-- ---------------------------------------------------------------------------
INSERT INTO `custom_ej_tier` (`id`, `name`, `flags`, `sort_order`) VALUES
(9001, 'Classic', 0, 10),
(9002, 'Wrath of the Lich King', 0, 20);
-- ---------------------------------------------------------------------------
-- Dungeon: The Deadmines (map 36)
-- ---------------------------------------------------------------------------
-- instance.flags = 0 (dungeon)
INSERT INTO `custom_ej_instance`
(`id`, `name`, `description`, `button_icon`, `small_button_icon`,
`background`, `lore_background`, `map_id`, `area_id`, `world_map_area_id`,
`flags`, `sort_order`)
VALUES
(9001, 'The Deadmines',
'Beneath the quiet town of Moonbrook lies the Defias Brotherhood''s '
'shipyard, where Edwin VanCleef forges his revenge against the city of '
'Stormwind.',
'Interface\\EncounterJournal\\UI-EJ-DUNGEONBUTTON-DEADMINES',
'Interface\\EncounterJournal\\UI-EJ-SMALLBUTTON-DEADMINES',
'Interface\\EncounterJournal\\UI-EJ-DUNGEON-DEADMINES',
'',
36, 1581, 0,
0, 10);
INSERT INTO `custom_ej_tier_instance` (`tier_id`, `instance_id`, `sort_order`)
VALUES (9001, 9001, 10);
-- Encounter: Edwin VanCleef
INSERT INTO `custom_ej_encounter`
(`id`, `instance_id`, `name`, `description`,
`map_position_x`, `map_position_y`, `floor_index`, `world_map_area_id`,
`first_section_id`, `difficulty_mask`, `flags`, `sort_order`)
VALUES
(9001, 9001, 'Edwin VanCleef',
'Once a master stonemason, VanCleef built much of postwar Stormwind. '
'Cheated of his pay, he founded the Defias Brotherhood to take revenge.',
0.85, 0.65, 1, 0,
9001, 0x1, 0, 100);
-- Creature card
INSERT INTO `custom_ej_creature`
(`id`, `encounter_id`, `creature_id`, `name`, `description`,
`creature_display_id`, `icon`, `difficulty_mask`, `sort_order`)
VALUES
(9001, 9001, 639, 'Edwin VanCleef',
'Leader of the Defias Brotherhood.',
822, '', 0x1, 10);
-- Sections
-- Tree: 9001 (Overview) -> next 9002 (Abilities)
-- sub 9003 (Strike)
INSERT INTO `custom_ej_section`
(`id`, `encounter_id`, `parent_section_id`, `next_section_id`,
`sub_section_id`, `name`, `description`,
`creature_display_id`, `description_spell_id`, `icon_spell_id`,
`icon`, `flags`, `icon_flags`, `sort_order`, `type`,
`difficulty_mask`, `creature_id`)
VALUES
(9001, 9001, 0, 9002, 0,
'Overview',
'VanCleef calls in waves of his lieutenants before engaging the party '
'directly. Stay mobile and interrupt his casts.',
0, 0, 0, NULL, 0x1, 0, 10, 0, 0x1, 639),
(9002, 9001, 0, 0, 9003,
'Abilities',
'', 0, 0, 0, NULL, 0, 0, 20, 0, 0x1, 639),
(9003, 9001, 9002, 0, 0,
'Strike',
'A heavy melee strike on VanCleef''s current target.',
0, 6975, 6975, NULL, 0, 0, 10, 0, 0x1, 639);
-- Loot (real WotLK items): Cruel Barb (mh sword), Buccaneer''s Bracers
INSERT INTO `custom_ej_loot`
(`encounter_id`, `item_id`, `difficulty_mask`, `faction_mask`,
`class_mask`, `flags`, `sort_order`)
VALUES
(9001, 1937, 0x1, -1, -1, 0, 10), -- Cruel Barb
(9001, 2169, 0x1, -1, -1, 0, 20); -- Buccaneer''s Bracers
-- ---------------------------------------------------------------------------
-- Raid: Naxxramas (map 533)
-- ---------------------------------------------------------------------------
-- instance.flags = 0x10 (raid)
INSERT INTO `custom_ej_instance`
(`id`, `name`, `description`, `button_icon`, `small_button_icon`,
`background`, `lore_background`, `map_id`, `area_id`, `world_map_area_id`,
`flags`, `sort_order`)
VALUES
(9101, 'Naxxramas',
'Necropolis of the lich Kel''Thuzad, anchored above the Dragonblight. '
'Liberated and reactivated as a fortress of the Scourge.',
'Interface\\EncounterJournal\\UI-EJ-RAIDBUTTON-NAXXRAMAS',
'Interface\\EncounterJournal\\UI-EJ-SMALLBUTTON-NAXXRAMAS',
'Interface\\EncounterJournal\\UI-EJ-RAID-NAXXRAMAS',
'',
533, 3456, 0,
0x10, 10);
INSERT INTO `custom_ej_tier_instance` (`tier_id`, `instance_id`, `sort_order`)
VALUES (9002, 9101, 10);
-- Encounter: Patchwerk (10/25, normal/heroic combinations -> mask 0xF)
INSERT INTO `custom_ej_encounter`
(`id`, `instance_id`, `name`, `description`,
`map_position_x`, `map_position_y`, `floor_index`, `world_map_area_id`,
`first_section_id`, `difficulty_mask`, `flags`, `sort_order`)
VALUES
(9101, 9101, 'Patchwerk',
'Kel''Thuzad''s favored creation: an abomination stitched from the '
'corpses of more than a hundred victims.',
0.40, 0.50, 1, 0,
9101, 0xF, 0, 100);
INSERT INTO `custom_ej_creature`
(`id`, `encounter_id`, `creature_id`, `name`, `description`,
`creature_display_id`, `icon`, `difficulty_mask`, `sort_order`)
VALUES
(9101, 9101, 16028, 'Patchwerk',
'Kel''Thuzad''s flagship abomination.',
23323, '', 0xF, 10);
INSERT INTO `custom_ej_section`
(`id`, `encounter_id`, `parent_section_id`, `next_section_id`,
`sub_section_id`, `name`, `description`,
`creature_display_id`, `description_spell_id`, `icon_spell_id`,
`icon`, `flags`, `icon_flags`, `sort_order`, `type`,
`difficulty_mask`, `creature_id`)
VALUES
(9101, 9101, 0, 9102, 0,
'Overview',
'Tank-and-spank with a fixed enrage timer. Two well-geared tanks must '
'soak Hateful Strikes; healers run hot from the start.',
0, 0, 0, NULL, 0x1, 0, 10, 0, 0xF, 16028),
(9102, 9101, 0, 0, 9103,
'Abilities',
'', 0, 0, 0, NULL, 0, 0, 20, 0, 0xF, 16028),
(9103, 9101, 9102, 0, 0,
'Hateful Strike',
'Patchwerk strikes the highest-health target within melee range for '
'massive physical damage.',
0, 28308, 28308, NULL, 0, 0, 10, 0, 0xF, 16028);
-- Loot: a few real Patchwerk drops (10-normal mask 0x1, 25-normal mask 0x2)
INSERT INTO `custom_ej_loot`
(`encounter_id`, `item_id`, `difficulty_mask`, `faction_mask`,
`class_mask`, `flags`, `sort_order`)
VALUES
(9101, 39262, 0x1, -1, -1, 0, 10), -- Stained-Glass Shard of Light
(9101, 39263, 0x1, -1, -1, 0, 20), -- Wraith Spear
(9101, 40548, 0x2, -1, -1, 0, 30), -- Patchwork Bracers (25-man)
(9101, 40549, 0x2, -1, -1, 0, 40); -- Severed Visionary Tendrils (25-man)
COMMIT;
@@ -0,0 +1,125 @@
# EncounterJournal export format
This document fixes the field order inside each row of the JSON / Lua
export. Same order in JSON arrays and Lua tables; the only difference is
the surrounding container syntax.
## `tiers`
Array. Stable order: `sort_order ASC, id ASC`.
```text
[ id, name, flags, sort_order ]
```
## `instances` — keyed by `instance_id`
```text
[
name,
description,
button_icon,
small_button_icon,
background,
lore_background,
map_id,
area_id,
sort_order,
flags,
id,
world_map_area_id
]
```
## `tierInstances` — keyed by `tier_id`
```text
[ instance_id, instance_id, ... ]
```
Stable order: `tier_instance.sort_order ASC, instance_id ASC`.
## `encounters` — keyed by `instance_id`
Array of rows; stable order `sort_order ASC, id ASC`.
```text
[
id,
name,
description,
map_position_x,
map_position_y,
floor_index,
world_map_area_id,
first_section_id,
instance_id,
difficulty_mask,
flags,
sort_order
]
```
## `creatures` — keyed by `encounter_id`
Array of rows; stable order `sort_order ASC, id ASC`.
```text
[
name,
description,
creature_display_id,
icon,
encounter_id,
sort_order,
id,
creature_id,
difficulty_mask
]
```
## `sections` — keyed by `section_id`
Single row per key.
```text
[
id,
name,
description,
creature_display_id,
description_spell_id,
icon_spell_id,
encounter_id,
next_section_id,
sub_section_id,
parent_section_id,
flags,
icon_flags,
sort_order,
type,
difficulty_mask,
creature_id
]
```
## `items` — keyed by `encounter_id`
Array of rows; stable order `sort_order ASC, id ASC`.
```text
[
item_id,
encounter_id,
difficulty_mask,
faction_mask,
flags,
id,
class_mask
]
```
> Item name / icon / quality are intentionally **not** part of this
> payload. The MoonWellClient EncounterJournal addon resolves them via
> the regular `GetItemInfo` API; if you need an offline cache, generate
> a separate `ItemCache.lua` from `item_template`.
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env bash
MOD_ENCOUNTER_JOURNAL_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/" && pwd )"
@@ -0,0 +1,182 @@
/*
* This file is part of the AzerothCore Project. See AUTHORS file for Copyright information.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License v2 as published by the
* Free Software Foundation.
*/
#include "EncounterJournalMgr.h"
#include "Chat.h"
#include "CommandScript.h"
#include <string>
#include <vector>
using namespace Acore::ChatCommands;
class encounter_journal_commandscript : public CommandScript
{
public:
encounter_journal_commandscript() : CommandScript("encounter_journal_commandscript") { }
ChatCommandTable GetCommands() const override
{
static ChatCommandTable ejCommandTable =
{
{ "reload", HandleReloadCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "export", HandleExportCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "validate", HandleValidateCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "importloot", HandleImportLootCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "importskeleton", HandleImportSkeletonCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "importall", HandleImportAllCommand, SEC_ADMINISTRATOR, Console::Yes },
{ "info", HandleInfoCommand, SEC_GAMEMASTER, Console::Yes },
};
static ChatCommandTable commandTable =
{
{ "ej", ejCommandTable },
};
return commandTable;
}
static bool HandleReloadCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
handler->PSendSysMessage("Encounter Journal reloaded ({} instances, "
"{} encounters, {} sections, {} loot rows).",
sEncounterJournalMgr->InstanceCount(),
sEncounterJournalMgr->EncounterCount(),
sEncounterJournalMgr->SectionCount(),
sEncounterJournalMgr->LootCount());
return true;
}
static bool HandleExportCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
std::vector<std::string> paths;
std::string error;
if (!sEncounterJournalMgr->Export(paths, error))
{
handler->SendErrorMessage("Encounter Journal export failed: {}", error);
return false;
}
handler->PSendSysMessage("Encounter Journal exported {} file(s):", paths.size());
for (std::string const& p : paths)
handler->PSendSysMessage(" {}", p);
return true;
}
static bool HandleValidateCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
std::vector<std::string> errors;
std::size_t count = sEncounterJournalMgr->Validate(errors);
if (count == 0)
{
handler->PSendSysMessage("Encounter Journal: validation OK "
"({} instances, {} encounters, {} sections, {} loot rows).",
sEncounterJournalMgr->InstanceCount(),
sEncounterJournalMgr->EncounterCount(),
sEncounterJournalMgr->SectionCount(),
sEncounterJournalMgr->LootCount());
return true;
}
handler->PSendSysMessage("Encounter Journal: {} issue(s):", count);
// Cap output to avoid flooding chat in the in-game case.
std::size_t const limit = 50;
for (std::size_t i = 0; i < errors.size() && i < limit; ++i)
handler->PSendSysMessage(" {}", errors[i]);
if (errors.size() > limit)
handler->PSendSysMessage(" ... and {} more (run from console for full list)",
errors.size() - limit);
return true;
}
static bool HandleImportLootCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
std::vector<std::string> warnings;
std::size_t inserted = sEncounterJournalMgr->ImportLoot(warnings);
handler->PSendSysMessage(
"Encounter Journal loot import: inserted {} row(s).", inserted);
if (!warnings.empty())
{
handler->PSendSysMessage("{} warning(s):", warnings.size());
std::size_t const limit = 25;
for (std::size_t i = 0; i < warnings.size() && i < limit; ++i)
handler->PSendSysMessage(" {}", warnings[i]);
if (warnings.size() > limit)
handler->PSendSysMessage(" ... and {} more",
warnings.size() - limit);
}
if (inserted > 0)
handler->PSendSysMessage("Reload the manager (.ej reload) to "
"see the new rows in subsequent .ej export/validate.");
return true;
}
static bool HandleImportSkeletonCommand(ChatHandler* handler)
{
sEncounterJournalMgr->Load();
MoonWell::EncounterJournal::Mgr::SkeletonCounts c;
std::vector<std::string> warnings;
std::size_t total = sEncounterJournalMgr->ImportSkeleton(c, warnings);
handler->PSendSysMessage(
"Encounter Journal skeleton import: {} new rows.", total);
handler->PSendSysMessage(
" tiers={} instances={} tier_instances={}",
c.tiers, c.instances, c.tierInstances);
handler->PSendSysMessage(
" encounters={} creatures={} sections={}",
c.encounters, c.creatures, c.sections);
if (!warnings.empty())
{
handler->PSendSysMessage("{} warning(s):", warnings.size());
std::size_t const limit = 25;
for (std::size_t i = 0; i < warnings.size() && i < limit; ++i)
handler->PSendSysMessage(" {}", warnings[i]);
if (warnings.size() > limit)
handler->PSendSysMessage(" ... and {} more",
warnings.size() - limit);
}
if (total > 0)
handler->PSendSysMessage("Run .ej importloot next, then .ej export.");
return true;
}
static bool HandleImportAllCommand(ChatHandler* handler)
{
HandleImportSkeletonCommand(handler);
sEncounterJournalMgr->Load();
return HandleImportLootCommand(handler);
}
static bool HandleInfoCommand(ChatHandler* handler)
{
handler->PSendSysMessage("Encounter Journal cache:");
handler->PSendSysMessage(" tiers: {}", sEncounterJournalMgr->TierCount());
handler->PSendSysMessage(" instances: {}", sEncounterJournalMgr->InstanceCount());
handler->PSendSysMessage(" encounters: {}", sEncounterJournalMgr->EncounterCount());
handler->PSendSysMessage(" creatures: {}", sEncounterJournalMgr->CreatureCount());
handler->PSendSysMessage(" sections: {}", sEncounterJournalMgr->SectionCount());
handler->PSendSysMessage(" loot rows: {}", sEncounterJournalMgr->LootCount());
return true;
}
};
void AddSC_encounter_journal_commands()
{
new encounter_journal_commandscript();
}
@@ -0,0 +1,83 @@
/*
* This file is part of the AzerothCore Project. See AUTHORS file for Copyright information.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License v2 as published by the
* Free Software Foundation.
*/
#include "Config.h"
#include "EncounterJournalMgr.h"
#include "Log.h"
#include "ScriptMgr.h"
void AddSC_encounter_journal_commands();
class encounter_journal_world_script : public WorldScript
{
public:
encounter_journal_world_script()
: WorldScript("encounter_journal_world_script",
{ WORLDHOOK_ON_LOAD_CUSTOM_DATABASE_TABLE })
{ }
void OnLoadCustomDatabaseTable() override
{
if (!sConfigMgr->GetOption<bool>("EncounterJournal.Enable", true))
{
LOG_INFO("server.loading", ">> mod-encounter-journal disabled "
"via EncounterJournal.Enable=0.");
return;
}
sEncounterJournalMgr->Load();
if (sConfigMgr->GetOption<bool>("EncounterJournal.ImportOnStart", false))
{
MoonWell::EncounterJournal::Mgr::SkeletonCounts sc;
std::vector<std::string> warnings;
std::size_t inserted =
sEncounterJournalMgr->ImportSkeleton(sc, warnings);
LOG_INFO("server.loading",
">> mod-encounter-journal: ImportOnStart added {} skeleton rows "
"(tiers={}, instances={}, encounters={}, creatures={}, sections={}).",
inserted, sc.tiers, sc.instances, sc.encounters,
sc.creatures, sc.sections);
std::vector<std::string> lootWarn;
std::size_t lootRows = sEncounterJournalMgr->ImportLoot(lootWarn);
LOG_INFO("server.loading",
">> mod-encounter-journal: ImportOnStart added {} loot rows.",
lootRows);
sEncounterJournalMgr->Load();
}
if (!sConfigMgr->GetOption<bool>("EncounterJournal.ExportOnStart", false))
return;
std::vector<std::string> paths;
std::string error;
if (!sEncounterJournalMgr->Export(paths, error))
{
LOG_ERROR("server.loading",
">> mod-encounter-journal: ExportOnStart failed: {}", error);
return;
}
for (std::string const& p : paths)
LOG_INFO("server.loading",
">> mod-encounter-journal: wrote {}", p);
}
};
void Addmod_encounter_journalScripts()
{
if (!sConfigMgr->GetOption<bool>("EncounterJournal.Enable", true))
return;
new encounter_journal_world_script();
AddSC_encounter_journal_commands();
LOG_INFO("server.loading",
">> mod-encounter-journal scripts registered (.ej commands available).");
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
/*
* This file is part of the AzerothCore Project. See AUTHORS file for Copyright information.
*
* This program is free software; you can redistribute it and/or modify it
* under the terms of the GNU General Public License v2 as published by the
* Free Software Foundation.
*/
#ifndef MOONWELL_ENCOUNTER_JOURNAL_MGR_H
#define MOONWELL_ENCOUNTER_JOURNAL_MGR_H
#include "Define.h"
#include <cstddef>
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
namespace MoonWell::EncounterJournal
{
struct Tier
{
uint32 id = 0;
std::string name;
uint32 flags = 0;
int32 sortOrder = 0;
};
struct Instance
{
uint32 id = 0;
std::string name;
std::string description;
std::string buttonIcon;
std::string smallButtonIcon;
std::string background;
std::string loreBackground;
uint32 mapId = 0;
uint32 areaId = 0;
uint32 worldMapAreaId = 0;
uint32 flags = 0;
int32 sortOrder = 0;
};
struct TierInstance
{
uint32 tierId = 0;
uint32 instanceId = 0;
int32 sortOrder = 0;
};
struct Encounter
{
uint32 id = 0;
uint32 instanceId = 0;
std::string name;
std::string description;
float mapPositionX = 0.f;
float mapPositionY = 0.f;
uint32 floorIndex = 1;
uint32 worldMapAreaId = 0;
uint32 firstSectionId = 0;
uint32 difficultyMask = 0;
uint32 flags = 0;
int32 sortOrder = 0;
};
struct Creature
{
uint32 id = 0;
uint32 encounterId = 0;
uint32 creatureId = 0;
std::string name;
std::string description;
uint32 creatureDisplayId = 0;
std::string icon;
uint32 difficultyMask = 0;
int32 sortOrder = 0;
};
struct Section
{
uint32 id = 0;
uint32 encounterId = 0;
uint32 parentSectionId = 0;
uint32 nextSectionId = 0;
uint32 subSectionId = 0;
std::string name;
std::string description;
uint32 creatureDisplayId = 0;
uint32 descriptionSpellId = 0;
uint32 iconSpellId = 0;
std::string icon; // empty when SQL value is NULL or ''
uint32 flags = 0;
uint32 iconFlags = 0;
int32 sortOrder = 0;
uint32 type = 0;
uint32 difficultyMask = 0;
uint32 creatureId = 0;
};
struct Loot
{
uint32 id = 0;
uint32 encounterId = 0;
uint32 itemId = 0;
uint32 difficultyMask = 0;
int32 factionMask = -1;
int32 classMask = -1;
uint32 flags = 0;
int32 sortOrder = 0;
};
class Mgr
{
public:
static Mgr* instance();
// Loads all custom_ej_* tables into in-memory caches.
// Safe to call repeatedly: previous data is dropped first.
// Returns true on success (does not fail when tables are empty).
bool Load();
// Drops cached data.
void Unload();
// Writes the configured JSON file (always) and Lua file
// (when Export.LuaFile is non-empty). On success @writtenPaths
// contains the absolute paths created/overwritten.
bool Export(std::vector<std::string>& writtenPaths, std::string& error);
// Validates relational integrity. Returns the number of errors
// found and pushes one human-readable line per error into @errors.
std::size_t Validate(std::vector<std::string>& errors);
// Imports loot rows from creature_loot_template via creature_template.lootid
// for each creature card whose creature_id != 0. Reference rows are
// followed one level. Existing rows in custom_ej_loot are skipped via
// INSERT IGNORE on the unique key. Returns the number of inserted rows.
std::size_t ImportLoot(std::vector<std::string>& warnings);
// Generates skeleton EJ rows from existing AzerothCore tables:
// - custom_ej_tier from MapEntry.expansionID buckets
// - custom_ej_instance from instance_template + sMapStore (name, type)
// - custom_ej_encounter from instance_encounters (creditType=0 only)
// - custom_ej_creature from creature_template referenced by encounters
// - custom_ej_section one Overview placeholder per encounter
// Existing rows are kept (INSERT IGNORE on PK). Returns total inserted
// rows; per-table counts are reported via @counts (tier/instance/
// tier_instance/encounter/creature/section).
struct SkeletonCounts
{
std::size_t tiers = 0;
std::size_t instances = 0;
std::size_t tierInstances = 0;
std::size_t encounters = 0;
std::size_t creatures = 0;
std::size_t sections = 0;
};
std::size_t ImportSkeleton(SkeletonCounts& counts,
std::vector<std::string>& warnings);
std::size_t TierCount() const { return _tiers.size(); }
std::size_t InstanceCount() const { return _instances.size(); }
std::size_t EncounterCount() const { return _encounters.size(); }
std::size_t CreatureCount() const { return _creatures.size(); }
std::size_t SectionCount() const { return _sections.size(); }
std::size_t LootCount() const { return _loot.size(); }
private:
Mgr() = default;
std::string ResolveExportPath(std::string const& filename) const;
std::string BuildJson() const;
std::string BuildLua(std::string const& globalName) const;
std::vector<Tier> _tiers;
std::vector<TierInstance> _tierInstances;
std::unordered_map<uint32, Instance> _instances;
std::unordered_map<uint32, Encounter> _encounters;
std::unordered_map<uint32, Creature> _creatures;
std::unordered_map<uint32, Section> _sections;
std::vector<Loot> _loot;
// Sorted access caches (by sort_order, id). Filled at end of Load().
std::vector<uint32> _instancesSorted;
std::unordered_map<uint32, std::vector<uint32>> _encountersByInstance;
std::unordered_map<uint32, std::vector<uint32>> _creaturesByEncounter;
std::unordered_map<uint32, std::vector<std::size_t>> _lootByEncounter;
std::unordered_map<uint32, std::vector<uint32>> _instancesByTier;
};
}
#define sEncounterJournalMgr ::MoonWell::EncounterJournal::Mgr::instance()
#endif // MOONWELL_ENCOUNTER_JOURNAL_MGR_H
@@ -0,0 +1,164 @@
#!/usr/bin/env python3
"""
Build a trimmed item cache for the MoonWellClient EncounterJournal addon.
Reads one or more donor cache files (Sirus-style Generated_ItemsCache*.lua,
each holding rows like `[id] = { "name_en", "name_ru", quality, ..., "icon",
... }`), intersects them with the item ids referenced by
EncounterJournalData.json (the file produced by .ej export) and emits a
Lua file containing only the matching rows.
Output schema (single global, Lua):
MoonWellEncounterJournalItemCache = {
[<item_id>] = { name_en, name_ru, quality, icon },
...
}
Multiple donor files are merged left-to-right; later files override earlier
ones for duplicate ids. Items missing from every donor are reported on
stderr and skipped (the client falls back to GetItemInfo for those).
"""
from __future__ import annotations
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, Iterable, Set, Tuple
# Row layout per Sirus convention:
# [id] = { "name_en", "name_ru", quality, classId, subClass,
# inventoryType, slot, stackable, idx, "icon", spellId },
ROW_RE = re.compile(
r"""^\s*\[(?P<id>\d+)\]\s*=\s*\{\s*
"(?P<en>(?:[^"\\]|\\.)*)"\s*,\s*
"(?P<ru>(?:[^"\\]|\\.)*)"\s*,\s*
(?P<quality>-?\d+)\s*,\s*
-?\d+\s*,\s* # field 4
-?\d+\s*,\s* # field 5
-?\d+\s*,\s* # field 6
-?\d+\s*,\s* # field 7
-?\d+\s*,\s* # field 8
-?\d+\s*,\s* # field 9
"(?P<icon>(?:[^"\\]|\\.)*)"\s*,\s*
-?\d+\s*
\}\s*,?\s*$""",
re.VERBOSE,
)
def parse_donor(path: Path) -> Dict[int, Tuple[str, str, int, str]]:
"""Returns {id: (name_en, name_ru, quality, icon)} from one donor file."""
out: Dict[int, Tuple[str, str, int, str]] = {}
bad = 0
with path.open("r", encoding="utf-8") as f:
for line in f:
m = ROW_RE.match(line)
if not m:
stripped = line.strip()
if stripped and not stripped.startswith(
("--", "Items", "ItemsCache", "}", "{")
):
bad += 1
continue
out[int(m.group("id"))] = (
m.group("en"),
m.group("ru"),
int(m.group("quality")),
m.group("icon"),
)
if bad:
print(f"[{path.name}] skipped {bad} unparseable lines", file=sys.stderr)
return out
def collect_needed_ids(journal_json: Path) -> Set[int]:
"""Returns the set of item ids referenced in EncounterJournalData.json."""
with journal_json.open("r", encoding="utf-8") as f:
data = json.load(f)
needed: Set[int] = set()
for rows in data.get("items", {}).values():
for row in rows:
# items[encounterID] row layout per docs/encounter_journal_format.md:
# [item_id, encounter_id, difficulty_mask, faction_mask, flags,
# id, class_mask]
if row:
needed.add(int(row[0]))
return needed
def lua_str(s: str) -> str:
s = s.replace("\\", "\\\\").replace("'", "\\'")
s = s.replace("\n", "\\n").replace("\r", "\\r")
return f"'{s}'"
def write_lua(out_path: Path, global_name: str,
cache: Dict[int, Tuple[str, str, int, str]]) -> None:
with out_path.open("w", encoding="utf-8") as f:
f.write("-- Generated by mod-encounter-journal/tools/"
"build_item_cache.py.\n")
f.write("-- Trimmed donor item cache for items referenced by "
"EncounterJournalData.\n\n")
f.write(f"{global_name} = {{\n")
for item_id in sorted(cache.keys()):
en, ru, quality, icon = cache[item_id]
f.write(
f" [{item_id}] = {{ {lua_str(en)}, {lua_str(ru)}, "
f"{quality}, {lua_str(icon)} }},\n"
)
f.write("}\n")
def merge_donors(donor_paths: Iterable[Path]
) -> Dict[int, Tuple[str, str, int, str]]:
merged: Dict[int, Tuple[str, str, int, str]] = {}
for p in donor_paths:
rows = parse_donor(p)
print(f"[{p.name}] parsed {len(rows)} rows", file=sys.stderr)
merged.update(rows)
return merged
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--journal", "-j", required=True, type=Path,
help="EncounterJournalData.json (from .ej export)")
parser.add_argument("--donor", "-d", required=True, type=Path, nargs="+",
help="One or more Generated_ItemsCache*.lua files")
parser.add_argument("--output", "-o", required=True, type=Path,
help="Where to write EncounterJournalItemCache.lua")
parser.add_argument("--global", dest="global_name",
default="MoonWellEncounterJournalItemCache",
help="Lua global name (default: %(default)s)")
args = parser.parse_args()
needed = collect_needed_ids(args.journal)
print(f"EJ references {len(needed)} unique item ids", file=sys.stderr)
donors = merge_donors(args.donor)
print(f"donor cache holds {len(donors)} unique item ids", file=sys.stderr)
trimmed = {i: donors[i] for i in needed if i in donors}
missing = sorted(needed - donors.keys())
write_lua(args.output, args.global_name, trimmed)
print(
f"wrote {len(trimmed)} rows to {args.output} "
f"(missing in donor: {len(missing)})",
file=sys.stderr,
)
if missing:
sample = ", ".join(str(i) for i in missing[:15])
more = "" if len(missing) <= 15 else f" ... and {len(missing) - 15} more"
print(f"missing item ids (client will fall back to GetItemInfo): "
f"{sample}{more}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,577 @@
#!/usr/bin/env python3
"""
Import a Sirus-style Generated_EncounterJournal.lua dump into the
mod-encounter-journal custom_ej_* tables.
The dump holds seven top-level Lua tables that map 1:1 to our schema:
JOURNALINSTANCE -> custom_ej_instance
JOURNALENCOUNTER -> custom_ej_encounter
JOURNALENCOUNTERCREATURE -> custom_ej_creature
JOURNALENCOUNTERSECTION -> custom_ej_section
JOURNALENCOUNTERITEM -> custom_ej_loot
JOURNALTIER -> custom_ej_tier
JOURNALTIERXINSTANCE -> custom_ej_tier_instance
The script emits a single .sql file (or writes to stdout) that uses
REPLACE INTO so re-running is safe. Pipe it into your MySQL container:
python3 import_donor_journal.py -i Generated_EncounterJournal.lua \
-o donor_import.sql
docker exec -i ac-database mysql -uroot -p$PASS acore_world \
< donor_import.sql
Each row from the dump goes through a small tokenizer that understands
quoted strings ("..."), long-bracket strings ([[...]]), numbers, and
nested braces. The dump only uses single-line strings, so no multi-line
handling is needed.
"""
from __future__ import annotations
import argparse
import re
import sys
from pathlib import Path
from typing import Any, Iterable, List, Optional, TextIO, Tuple
# ---------------------------------------------------------------------------
# Tokeniser / value parser
# ---------------------------------------------------------------------------
class LuaParseError(RuntimeError):
pass
def _skip_ws(src: str, pos: int) -> int:
while pos < len(src) and src[pos] in " \t\r\n,":
pos += 1
return pos
def _parse_quoted_string(src: str, pos: int) -> Tuple[str, int]:
assert src[pos] == '"'
pos += 1
out: List[str] = []
while pos < len(src):
c = src[pos]
if c == "\\" and pos + 1 < len(src):
nxt = src[pos + 1]
# Lua escapes: \", \', \\, \n, \r, \t — keep verbatim semantics.
out.append({
'"': '"', "'": "'", "\\": "\\",
"n": "\n", "r": "\r", "t": "\t",
}.get(nxt, nxt))
pos += 2
continue
if c == '"':
return "".join(out), pos + 1
out.append(c)
pos += 1
raise LuaParseError("unterminated quoted string")
def _parse_long_bracket_string(src: str, pos: int) -> Tuple[str, int]:
assert src.startswith("[[", pos)
pos += 2
end = src.find("]]", pos)
if end < 0:
raise LuaParseError("unterminated long-bracket string")
return src[pos:end], end + 2
_NUM_RE = re.compile(r"-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?")
def _parse_number(src: str, pos: int) -> Tuple[float, int]:
m = _NUM_RE.match(src, pos)
if not m:
raise LuaParseError(f"expected number at {pos}: {src[pos:pos+20]!r}")
txt = m.group(0)
val: Any = float(txt) if ("." in txt or "e" in txt or "E" in txt) else int(txt)
return val, m.end()
def _parse_table(src: str, pos: int) -> Tuple[list, int]:
"""Parses `{ v1, v2, ... }` and returns the list of values."""
if src[pos] != "{":
raise LuaParseError(f"expected '{{' at {pos}")
pos += 1
out: list = []
while True:
pos = _skip_ws(src, pos)
if pos >= len(src):
raise LuaParseError("unterminated table literal")
if src[pos] == "}":
return out, pos + 1
value, pos = _parse_value(src, pos)
out.append(value)
def _parse_value(src: str, pos: int) -> Tuple[Any, int]:
pos = _skip_ws(src, pos)
c = src[pos]
if c == '"':
return _parse_quoted_string(src, pos)
if src.startswith("[[", pos):
return _parse_long_bracket_string(src, pos)
if c == "{":
return _parse_table(src, pos)
return _parse_number(src, pos)
# ---------------------------------------------------------------------------
# Top-level table extractor
# ---------------------------------------------------------------------------
def _find_top_level_tables(text: str) -> dict:
"""
Returns {table_name: dict_or_list}. Iterates the source once, splitting
at top-level `NAME = {` declarations and parsing the brace-balanced body.
"""
tables: dict = {}
pos = 0
pat = re.compile(r"^([A-Z_]+)\s*=\s*\{", re.MULTILINE)
while True:
m = pat.search(text, pos)
if not m:
break
name = m.group(1)
# Find brace-balanced end.
body_start = m.end() - 1 # position of '{'
depth = 0
i = body_start
in_str = None # '"' or '[' for long bracket
while i < len(text):
ch = text[i]
if in_str == '"':
if ch == "\\":
i += 2
continue
if ch == '"':
in_str = None
i += 1
continue
if in_str == "[":
if text.startswith("]]", i):
in_str = None
i += 2
continue
i += 1
continue
if ch == '"':
in_str = '"'
i += 1
continue
if text.startswith("[[", i):
in_str = "["
i += 2
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
if depth == 0:
body = text[body_start:i + 1]
tables[name] = _parse_top_level_body(name, body)
pos = i + 1
break
i += 1
else:
raise LuaParseError(f"unterminated table {name}")
return tables
def _parse_top_level_body(name: str, body: str) -> Any:
"""
Parses the contents of one top-level table, which can have either
keyed entries `[id] = { ... }` or sequential entries `{ ... }`.
Returns dict for keyed entries, list for sequential.
"""
assert body.startswith("{") and body.endswith("}")
inner = body[1:-1]
i = 0
keyed: dict = {}
seq: list = []
while True:
i = _skip_ws(inner, i)
if i >= len(inner):
break
if inner[i] == "[":
# `[key] = value`
j = inner.index("]", i)
key_val, _ = _parse_value(inner, i + 1)
i = j + 1
i = _skip_ws(inner, i)
if inner[i] != "=":
raise LuaParseError(f"{name}: expected '=' at {i}")
i = _skip_ws(inner, i + 1)
value, i = _parse_value(inner, i)
keyed[key_val] = value
else:
value, i = _parse_value(inner, i)
seq.append(value)
if keyed and seq:
raise LuaParseError(f"{name}: mix of keyed/sequential entries")
return keyed if keyed else seq
# ---------------------------------------------------------------------------
# SQL emission
# ---------------------------------------------------------------------------
def _sql_str(s: Any) -> str:
s = str(s)
return "'" + s.replace("\\", "\\\\").replace("'", "''") + "'"
def _to_int(v: Any, default: int = 0) -> int:
if v is None:
return default
if isinstance(v, bool):
return int(v)
return int(v)
def _to_uint(v: Any) -> int:
"""Sirus uses -1 as 'all difficulties'; our schema is UNSIGNED → store 0."""
n = _to_int(v)
return n if n >= 0 else 0
def _to_signed(v: Any) -> int:
return _to_int(v)
def _to_float(v: Any) -> float:
if v is None:
return 0.0
return float(v)
def _load_id_set(path: Optional[Path]) -> Optional[set]:
"""Reads one id per line, returns a set of ints. None when path is None."""
if path is None:
return None
out: set = set()
with path.open("r", encoding="utf-8") as f:
for line in f:
line = line.strip()
if not line or line.startswith("#"):
continue
try:
out.add(int(line))
except ValueError:
pass
return out
def emit(tables: dict, out: TextIO,
known_maps: Optional[set] = None,
known_creatures: Optional[set] = None,
known_items: Optional[set] = None,
dropped: Optional[dict] = None) -> dict:
"""
Writes REPLACE INTO statements for every donor table. Returns per-table
insert counts for the run summary.
When @known_maps/@known_creatures/@known_items are provided, rows that
reference an id missing from the corresponding set are skipped:
- instance with map_id not in known_maps is dropped (and all of its
encounters/creatures/sections/loot/tier_instance go with it);
- creature whose creature_id != 0 and not in known_creatures dropped;
- loot whose item_id not in known_items dropped;
- tier with no surviving tier_instance entries dropped.
"""
counts = {
"tier": 0, "instance": 0, "tier_instance": 0,
"encounter": 0, "creature": 0, "section": 0, "loot": 0,
}
if dropped is None:
dropped = {}
for k in ("instance", "creature", "loot", "tier"):
dropped.setdefault(k, 0)
out.write("-- Generated by mod-encounter-journal/tools/"
"import_donor_journal.py.\n")
out.write("-- Source: Sirus-style Generated_EncounterJournal.lua dump.\n")
out.write("-- Safe to re-run: every INSERT uses REPLACE semantics on PK.\n")
out.write("SET NAMES utf8mb4;\n")
out.write("START TRANSACTION;\n\n")
# First pass: figure out which instance_ids survive the map filter so we
# can transitively drop their encounters/creatures/sections/loot.
surviving_instances: set = set()
for inst_id, row in tables.get("JOURNALINSTANCE", {}).items():
if len(row) < 12:
row = row + [0] * (12 - len(row))
map_id = _to_int(row[6])
if known_maps is not None and map_id not in known_maps:
dropped["instance"] += 1
continue
surviving_instances.add(_to_int(inst_id))
# encounter_id -> instance_id (used by creature/section/loot filters)
encounter_to_instance: dict = {}
for inst_id, rows in tables.get("JOURNALENCOUNTER", {}).items():
for row in rows:
if len(row) < 12:
continue
encounter_to_instance[_to_int(row[0])] = _to_int(inst_id)
def _enc_survives(enc_id: int) -> bool:
inst = encounter_to_instance.get(enc_id)
return inst is not None and inst in surviving_instances
# ---- tiers ----
# Build the set of tiers that retain at least one surviving instance
# via JOURNALTIERXINSTANCE so we don't write orphan tiers.
surviving_tiers: set = set()
for inst_id, tirow in tables.get("JOURNALTIERXINSTANCE", {}).items():
if _to_int(inst_id) in surviving_instances and len(tirow) >= 1:
surviving_tiers.add(_to_int(tirow[0]))
for row in tables.get("JOURNALTIER", []):
# {id, name, flags}
tid, name, flags = row[0], row[1], row[2] if len(row) > 2 else 0
if surviving_tiers and _to_int(tid) not in surviving_tiers:
dropped["tier"] += 1
continue
out.write(
"REPLACE INTO `custom_ej_tier` "
"(`id`, `name`, `flags`, `sort_order`) VALUES "
f"({_to_int(tid)}, {_sql_str(name)}, {_to_int(flags)}, "
f"{_to_int(tid)});\n"
)
counts["tier"] += 1
out.write("\n")
# ---- instances ----
for inst_id, row in tables.get("JOURNALINSTANCE", {}).items():
if _to_int(inst_id) not in surviving_instances:
continue
if len(row) < 12:
row = row + [0] * (12 - len(row))
(name, desc, btn, sbtn, bg, lbg, map_id, area, sort_o,
flags, src_id, wmaa) = row[:12]
out.write(
"REPLACE INTO `custom_ej_instance` "
"(`id`, `name`, `description`, `button_icon`, "
"`small_button_icon`, `background`, `lore_background`, "
"`map_id`, `area_id`, `world_map_area_id`, `flags`, `sort_order`) "
"VALUES ("
f"{_to_int(inst_id)}, {_sql_str(name)}, {_sql_str(desc)}, "
f"{_sql_str(btn)}, {_sql_str(sbtn)}, {_sql_str(bg)}, "
f"{_sql_str(lbg)}, {_to_int(map_id)}, {_to_int(area)}, "
f"{_to_int(wmaa)}, {_to_int(flags)}, {_to_int(sort_o)}"
");\n"
)
counts["instance"] += 1
out.write("\n")
# ---- tier_instance ----
# JOURNALTIERXINSTANCE[instance_id] = {tier_id, sort_order}
for inst_id, row in tables.get("JOURNALTIERXINSTANCE", {}).items():
if _to_int(inst_id) not in surviving_instances:
continue
tier_id = row[0]
sort_o = row[1] if len(row) > 1 else 0
out.write(
"REPLACE INTO `custom_ej_tier_instance` "
"(`tier_id`, `instance_id`, `sort_order`) VALUES "
f"({_to_int(tier_id)}, {_to_int(inst_id)}, {_to_int(sort_o)});\n"
)
counts["tier_instance"] += 1
out.write("\n")
# ---- encounters ----
# JOURNALENCOUNTER[instance_id] = list of rows
# {id, name, desc, map_x, map_y, floor, world_map_area, first_section,
# instance_id, difficulty_mask, flags, sort_order}
for inst_id, rows in tables.get("JOURNALENCOUNTER", {}).items():
if _to_int(inst_id) not in surviving_instances:
continue
for row in rows:
if len(row) < 12:
row = row + [0] * (12 - len(row))
(eid, name, desc, mx, my, floor, wmaa, first_sec, src_inst,
diff, flags, sort_o) = row[:12]
out.write(
"REPLACE INTO `custom_ej_encounter` "
"(`id`, `instance_id`, `name`, `description`, "
"`map_position_x`, `map_position_y`, `floor_index`, "
"`world_map_area_id`, `first_section_id`, `difficulty_mask`, "
"`flags`, `sort_order`) VALUES ("
f"{_to_int(eid)}, {_to_int(inst_id)}, {_sql_str(name)}, "
f"{_sql_str(desc)}, {_to_float(mx):.6f}, {_to_float(my):.6f}, "
f"{_to_uint(floor)}, {_to_uint(wmaa)}, {_to_int(first_sec)}, "
f"{_to_uint(diff)}, {_to_uint(flags)}, {_to_int(sort_o)}"
");\n"
)
counts["encounter"] += 1
out.write("\n")
# ---- creatures ----
# JOURNALENCOUNTERCREATURE[encounter_id] = list of rows
# {name, desc, display_id, icon, encounter_id, sort, id, creature_id,
# difficulty_mask}
for enc_id, rows in tables.get("JOURNALENCOUNTERCREATURE", {}).items():
if not _enc_survives(_to_int(enc_id)):
continue
for row in rows:
if len(row) < 9:
row = row + [0] * (9 - len(row))
(name, desc, display_id, icon, src_enc, sort_o, cid, cr_id,
diff) = row[:9]
cr_id_i = _to_int(cr_id)
if (known_creatures is not None and cr_id_i != 0
and cr_id_i not in known_creatures):
dropped["creature"] += 1
continue
out.write(
"REPLACE INTO `custom_ej_creature` "
"(`id`, `encounter_id`, `creature_id`, `name`, `description`, "
"`creature_display_id`, `icon`, `difficulty_mask`, "
"`sort_order`) VALUES ("
f"{_to_int(cid)}, {_to_int(enc_id)}, {_to_int(cr_id)}, "
f"{_sql_str(name)}, {_sql_str(desc)}, {_to_uint(display_id)}, "
f"{_sql_str(icon)}, {_to_uint(diff)}, {_to_int(sort_o)}"
");\n"
)
counts["creature"] += 1
out.write("\n")
# ---- sections ----
# JOURNALENCOUNTERSECTION[section_id] = single row
# {id, name, desc, display_id, desc_spell, icon_spell, encounter_id,
# next, sub, parent, flags, icon_flags, sort, type, difficulty,
# creature_id}
for sid, row in tables.get("JOURNALENCOUNTERSECTION", {}).items():
if len(row) < 16:
row = row + [0] * (16 - len(row))
(src_sid, name, desc, display_id, desc_spell, icon_spell, enc_id,
next_sid, sub_sid, parent_sid, flags, icon_flags, sort_o, stype,
diff, cr_id) = row[:16]
if not _enc_survives(_to_int(enc_id)):
continue
out.write(
"REPLACE INTO `custom_ej_section` "
"(`id`, `encounter_id`, `parent_section_id`, `next_section_id`, "
"`sub_section_id`, `name`, `description`, `creature_display_id`, "
"`description_spell_id`, `icon_spell_id`, `icon`, `flags`, "
"`icon_flags`, `sort_order`, `type`, `difficulty_mask`, "
"`creature_id`) VALUES ("
f"{_to_int(sid)}, {_to_int(enc_id)}, {_to_int(parent_sid)}, "
f"{_to_int(next_sid)}, {_to_int(sub_sid)}, {_sql_str(name)}, "
f"{_sql_str(desc)}, {_to_uint(display_id)}, "
f"{_to_uint(desc_spell)}, {_to_uint(icon_spell)}, NULL, "
f"{_to_uint(flags)}, {_to_uint(icon_flags)}, {_to_int(sort_o)}, "
f"{_to_uint(stype)}, {_to_uint(diff)}, {_to_uint(cr_id)}"
");\n"
)
counts["section"] += 1
out.write("\n")
# ---- loot ----
# JOURNALENCOUNTERITEM[encounter_id] = list of rows
# {item_id, encounter_id, difficulty_mask, faction_mask, flags, id,
# class_mask}
for enc_id, rows in tables.get("JOURNALENCOUNTERITEM", {}).items():
if not _enc_survives(_to_int(enc_id)):
continue
for row in rows:
if len(row) < 7:
row = row + [0] * (7 - len(row))
(item_id, src_enc, diff, faction, flags, lid, class_mask) = row[:7]
item_i = _to_int(item_id)
if known_items is not None and item_i not in known_items:
dropped["loot"] += 1
continue
out.write(
"REPLACE INTO `custom_ej_loot` "
"(`id`, `encounter_id`, `item_id`, `difficulty_mask`, "
"`faction_mask`, `class_mask`, `flags`, `sort_order`) "
"VALUES ("
f"{_to_int(lid)}, {_to_int(enc_id)}, {_to_int(item_id)}, "
f"{_to_uint(diff)}, {_to_signed(faction)}, "
f"{_to_signed(class_mask)}, {_to_uint(flags)}, "
f"{_to_int(lid)}"
");\n"
)
counts["loot"] += 1
out.write("\n")
out.write("COMMIT;\n")
return counts
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def main() -> int:
p = argparse.ArgumentParser(description=__doc__)
p.add_argument("--input", "-i", required=True, type=Path,
help="Generated_EncounterJournal.lua")
p.add_argument("--output", "-o", type=Path,
help="Where to write the .sql file (default: stdout)")
p.add_argument("--known-maps", type=Path,
help="File with allowed map ids (one per line). Donor "
"instances whose map_id is not listed are dropped, "
"together with their encounters/creatures/sections/"
"loot.")
p.add_argument("--known-creatures", type=Path,
help="File with allowed creature_template entries. Donor "
"creature cards with creature_id not in the list are "
"dropped.")
p.add_argument("--known-items", type=Path,
help="File with allowed item_template entries. Donor loot "
"rows referencing missing items are dropped.")
args = p.parse_args()
text = args.input.read_text(encoding="utf-8")
tables = _find_top_level_tables(text)
print("parsed tables:", file=sys.stderr)
for name, body in tables.items():
size = len(body) if isinstance(body, (dict, list)) else "?"
print(f" {name:30s} {size}", file=sys.stderr)
known_maps = _load_id_set(args.known_maps)
known_creatures = _load_id_set(args.known_creatures)
known_items = _load_id_set(args.known_items)
if known_maps is not None:
print(f"map whitelist: {len(known_maps)} ids", file=sys.stderr)
if known_creatures is not None:
print(f"creature whitelist: {len(known_creatures)} ids",
file=sys.stderr)
if known_items is not None:
print(f"item whitelist: {len(known_items)} ids", file=sys.stderr)
dropped: dict = {}
if args.output:
with args.output.open("w", encoding="utf-8") as f:
counts = emit(tables, f, known_maps, known_creatures,
known_items, dropped)
else:
counts = emit(tables, sys.stdout, known_maps, known_creatures,
known_items, dropped)
print("emitted rows:", file=sys.stderr)
for k, v in counts.items():
print(f" {k:14s} {v}", file=sys.stderr)
if any(dropped.values()):
print("dropped (custom Sirus entries):", file=sys.stderr)
for k, v in dropped.items():
if v:
print(f" {k:14s} {v}", file=sys.stderr)
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,125 @@
#!/usr/bin/env python3
"""
Convert an EncounterJournal JSON dump into a Lua file the
MoonWellClient EncounterJournal addon can `loadstring`/`include`.
Field order, key sort and structure mirror what the C++ exporter
in mod-encounter-journal writes via .ej export. Use this when the
JSON is produced outside the worldserver (e.g., from a CI job that
queries acore_world directly).
"""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Iterable
def lua_str(value: Any) -> str:
s = str(value)
s = s.replace("\\", "\\\\")
s = s.replace("'", "\\'")
s = s.replace("\n", "\\n")
s = s.replace("\r", "\\r")
return f"'{s}'"
def lua_value(value: Any) -> str:
if isinstance(value, bool):
return "true" if value else "false"
if isinstance(value, (int, float)):
# Integers stay int; floats use repr for round-trip stability.
if isinstance(value, float) and value.is_integer():
return str(int(value))
return repr(value) if isinstance(value, float) else str(value)
if value is None:
return "nil"
return lua_str(value)
def lua_array(values: Iterable[Any]) -> str:
return "{ " + ", ".join(lua_value(v) for v in values) + " }"
def emit_keyed_rows(out: list[str], name: str, mapping: dict, indent: str) -> None:
out.append(f"{indent}{name} = {{\n")
for key in sorted(mapping.keys(), key=lambda k: int(k)):
rows = mapping[key]
if rows and isinstance(rows[0], list):
out.append(f"{indent} [{int(key)}] = {{\n")
for row in rows:
out.append(f"{indent} {lua_array(row)},\n")
out.append(f"{indent} }},\n")
else:
# Single row OR flat list of ids (tierInstances).
out.append(f"{indent} [{int(key)}] = {lua_array(rows)},\n")
out.append(f"{indent}}},\n")
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--input", "-i", required=True,
help="Path to EncounterJournalData.json")
parser.add_argument("--output", "-o", required=True,
help="Path to write EncounterJournalData.lua")
parser.add_argument("--global", "-g", dest="global_name",
default="MoonWellEncounterJournalData",
help="Name of the Lua global to assign")
args = parser.parse_args()
src = Path(args.input)
if not src.exists():
print(f"input file not found: {src}", file=sys.stderr)
return 1
with src.open("r", encoding="utf-8") as f:
data = json.load(f)
out: list[str] = []
out.append("-- Generated by mod-encounter-journal/tools/json_to_lua.py.\n")
out.append("-- Source of truth: custom_ej_* tables in acore_world.\n\n")
out.append(f"{args.global_name} = {{\n")
# tiers (flat array)
out.append(" tiers = {\n")
for tier in data.get("tiers", []):
out.append(f" {lua_array(tier)},\n")
out.append(" },\n")
# instances : map id -> single row
instances = data.get("instances", {})
out.append(" instances = {\n")
for key in sorted(instances.keys(), key=lambda k: int(k)):
out.append(f" [{int(key)}] = {lua_array(instances[key])},\n")
out.append(" },\n")
# tierInstances : map tier_id -> [instance_id, ...]
emit_keyed_rows(out, "tierInstances", data.get("tierInstances", {}), " ")
# encounters : map instance_id -> [[row], ...]
emit_keyed_rows(out, "encounters", data.get("encounters", {}), " ")
# creatures : map encounter_id -> [[row], ...]
emit_keyed_rows(out, "creatures", data.get("creatures", {}), " ")
# sections : map section_id -> single row
sections = data.get("sections", {})
out.append(" sections = {\n")
for key in sorted(sections.keys(), key=lambda k: int(k)):
out.append(f" [{int(key)}] = {lua_array(sections[key])},\n")
out.append(" },\n")
# items : map encounter_id -> [[row], ...]
emit_keyed_rows(out, "items", data.get("items", {}), " ")
out.append("}\n")
Path(args.output).write_text("".join(out), encoding="utf-8")
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -336,3 +336,40 @@ IndividualProgression.ExcludeAccounts = 1
# Default: "^RNDBOT.*"
#
IndividualProgression.ExcludedAccountsRegex = "^RNDBOT.*"
########################################
# Player Guide (Путеводитель)
########################################
# IndividualProgression.PlayerGuide.Enable
#
# Description: Enable server-side Player Guide payload generation
# and addon-channel delivery. When disabled the
# module does not push any data to clients and
# ignores client commands.
#
# Default: 1 - Enabled
# 0 - Disabled
#
IndividualProgression.PlayerGuide.Enable = 1
# IndividualProgression.PlayerGuide.ChunkSize
#
# Description: Maximum size, in bytes, of a single addon-channel
# chunk used to deliver guide payloads. Lower values
# produce more chunks; higher values risk hitting
# the 255-byte client limit. Clamped to [64, 240].
#
# Default: 230
#
IndividualProgression.PlayerGuide.ChunkSize = 230
# IndividualProgression.PlayerGuide.LogPayloads
#
# Description: Emit a debug log line for each payload pushed to
# a client. Useful when developing the client UI.
#
# Default: 0 - Disabled
# 1 - Enabled
#
IndividualProgression.PlayerGuide.LogPayloads = 0
@@ -47,10 +47,6 @@ public:
sIndividualProgression->CheckAdjustments(player);
if (sIndividualProgression->enabled)
{
ChatHandler(player->GetSession()).SendSysMessage("|cff00ff00Individual Progression: |cffccccccenabled|r");
}
}
void OnPlayerSetMaxLevel(Player* player, uint32& maxPlayerLevel) override
@@ -42,6 +42,7 @@ void AddSC_ipp_spell_scripts();
void AddSC_individualProgression_commandscript();
void AddSC_mod_individual_progression_awareness();
void AddSC_mod_individual_progression_player();
void AddSC_mod_individual_progression_player_guide();
void AddSC_npc_archmage_timear();
void AddSC_karazhan_70();
void AddSC_the_eye_70();
@@ -88,6 +89,7 @@ void Addmod_individual_progressionScripts()
AddSC_individualProgression_commandscript();
AddSC_mod_individual_progression_awareness();
AddSC_mod_individual_progression_player();
AddSC_mod_individual_progression_player_guide();
AddSC_npc_archmage_timear();
AddSC_karazhan_70();
AddSC_the_eye_70();
@@ -0,0 +1,649 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#include "PlayerGuideMgr.h"
#include "../IndividualProgression.h"
#include "Config.h"
#include "DatabaseEnv.h"
#include "Field.h"
#include "Log.h"
#include "Player.h"
#include "QueryResult.h"
#include "QuestDef.h"
#include <algorithm>
#include <cstdio>
#include <cstdlib>
#include <set>
#include <sstream>
#include <unordered_set>
namespace MoonWell::PlayerGuide
{
namespace
{
std::string EscapeJson(std::string const& in)
{
std::string out;
out.reserve(in.size() + 2);
for (unsigned char c : in)
{
switch (c)
{
case '"': out += "\\\""; break;
case '\\': out += "\\\\"; break;
case '\b': out += "\\b"; break;
case '\f': out += "\\f"; break;
case '\n': out += "\\n"; break;
case '\r': out += "\\r"; break;
case '\t': out += "\\t"; break;
default:
if (c < 0x20)
{
char buf[8];
std::snprintf(buf, sizeof(buf), "\\u%04x", c);
out += buf;
}
else
out += static_cast<char>(c);
break;
}
}
return out;
}
// Writes "key":number when value > 0.
void WriteOptUInt(std::ostringstream& s, char const* key, uint32 v,
bool& first)
{
if (!v)
return;
if (!first) s << ',';
s << '"' << key << "\":" << v;
first = false;
}
void WriteString(std::ostringstream& s, char const* key,
std::string const& v, bool& first)
{
if (!first) s << ',';
s << '"' << key << "\":\"" << EscapeJson(v) << '"';
first = false;
}
void WriteUInt(std::ostringstream& s, char const* key, uint32 v,
bool& first)
{
if (!first) s << ',';
s << '"' << key << "\":" << v;
first = false;
}
void WriteBool(std::ostringstream& s, char const* key, bool v,
bool& first)
{
if (!first) s << ',';
s << '"' << key << "\":" << (v ? "true" : "false");
first = false;
}
// Maps an IPP ProgressionState into a target stage (1..N).
// The stage ids are kept stable so the client can key
// localisations off of them.
struct StageInfo
{
uint32 id;
char const* name;
char const* description;
};
StageInfo const STAGES[] =
{
{ 1, "Начало пути",
"Завершите низкоуровневое содержимое родного континента." },
{ 2, "Огненные Недра",
"Победите Рагнароса в Огненных Недрах." },
{ 3, "Логово Ониксии",
"Победите Ониксию." },
{ 4, "Логово Крыла Тьмы",
"Победите Нефариана в Логове Крыла Тьмы." },
{ 5, "Подготовка к АQ",
"Завершите подготовку к открытию ворот Ан'Киража." },
{ 6, "Военный поход АQ",
"Окажите помощь военным усилиям в Силитусе." },
{ 7, "Храм Ан'Киража",
"Победите Ц'Туна в Храме Ан'Киража." },
{ 8, "Наксрамас (40)",
"Победите КелТузада в Наксрамасе." },
{ 9, "Подготовка к Запределью",
"Откройте Тёмный портал и подготовьтесь к TBC." },
{ 10, "Каражан и Гру'ул",
"Завершите первый ярус рейдов TBC." },
{ 11, "Пещера Змеиных Недр / Око Бури",
"Завершите рейды второго яруса TBC." },
{ 12, "Вершина Хиджала и Чёрный храм",
"Победите Иллидана в Чёрном храме." },
{ 13, "Зул'Аман",
"Получите доступ к Зул'Аману и завершите его." },
{ 14, "Плато Солнечного Колодца",
"Победите Кил'джедена на Плато Солнечного Колодца." },
{ 15, "Наксрамас (Нордскол)",
"Завершите рейды первого яруса WotLK." },
{ 16, "Ульдуар",
"Победите Йогг-Сарона в Ульдуаре." },
{ 17, "Испытание крестоносца",
"Завершите Испытание крестоносца." },
{ 18, "Ледяная Корона",
"Победите Короля-лича." },
{ 19, "Рубиновое святилище",
"Победите Халиона в Рубиновом святилище." }
};
StageInfo const* FindStage(uint32 id)
{
for (StageInfo const& s : STAGES)
if (s.id == id)
return &s;
return nullptr;
}
// Tiered dungeon recommendations matched to the player level/stage.
struct DungeonRec
{
uint32 stage; // recommended starting from this stage
uint32 maxStage; // hidden after this stage (inclusive)
uint8 minLevel;
uint8 maxLevel;
uint32 mapId;
char const* title;
};
DungeonRec const DUNGEONS[] =
{
{ 1, 1, 15, 21, 36, "Мёртвые копи" },
{ 1, 1, 17, 24, 33, "Курганы Иглошкурых" },
{ 1, 2, 22, 30, 47, "Шахта Каражан" },
{ 1, 2, 24, 32, 70, "Ульдаман" },
{ 1, 3, 27, 38, 109, "Затонувший храм" },
{ 1, 3, 30, 42, 209, "Зул'Фаррак" },
{ 1, 4, 46, 54, 229, "Верхняя часть Чёрной горы" },
{ 1, 4, 55, 60, 230, "Глубины Чёрной горы" },
{ 1, 4, 58, 60, 533, "Наксрамас" },
{ 9, 12, 58, 70, 540, "Кузня Крови" },
{ 9, 12, 60, 70, 542, "Цитадель Адского пламени" },
{ 9, 12, 65, 70, 553, "Ботаника" },
{ 9, 12, 67, 70, 555, "Гробница Ткача Смерти" },
{14, 19, 68, 80, 574, "Утгард" },
{14, 19, 70, 80, 600, "Драк'Тарон" },
{14, 19, 72, 80, 619, "Ан'Кахет: Старое Королевство" },
{14, 19, 74, 80, 595, "Чертоги Камней" },
{14, 19, 76, 80, 658, "Очищение Стратхольма" }
};
}
Mgr* Mgr::instance()
{
static Mgr inst;
return &inst;
}
void Mgr::LoadConfig()
{
_enabled = sConfigMgr->GetOption<bool>(
"IndividualProgression.PlayerGuide.Enable", true);
_logPayloads = sConfigMgr->GetOption<bool>(
"IndividualProgression.PlayerGuide.LogPayloads", false);
_chunkSize = sConfigMgr->GetOption<uint32>(
"IndividualProgression.PlayerGuide.ChunkSize", 230);
// The addon channel hard-caps a single message body to 255
// characters. We leave room for the framing header.
if (_chunkSize < 64) _chunkSize = 64;
if (_chunkSize > 240) _chunkSize = 240;
ReloadEncounterJournalCache();
}
void Mgr::ReloadEncounterJournalCache()
{
_ejByMapId.clear();
// mod-encounter-journal may be disabled or its table absent.
// The query then yields no result and we keep an empty cache;
// recommendations that would have used EJ ids are skipped.
QueryResult result = WorldDatabase.Query(
"SELECT `id`, `map_id`, `name` "
"FROM `custom_ej_instance`");
if (!result)
{
LOG_INFO("server.loading",
">> PlayerGuide: no custom_ej_instance rows found. "
"Recommendations will be empty.");
return;
}
do
{
Field* f = result->Fetch();
EjInstanceRef ref;
ref.ejId = f[0].Get<uint32>();
uint32 mapId = f[1].Get<uint32>();
ref.name = f[2].Get<std::string>();
// First entry wins. If the EJ has duplicates for the same
// map (e.g. heroic variants) the lowest id is taken via the
// INSERT order; we don't reorder.
_ejByMapId.emplace(mapId, std::move(ref));
} while (result->NextRow());
LOG_INFO("server.loading",
">> PlayerGuide: cached {} EJ instance entries by mapId.",
_ejByMapId.size());
}
std::string Mgr::StageName(uint32 stage) const
{
StageInfo const* s = FindStage(stage);
return s ? s->name : "Этап";
}
std::string Mgr::StageDescription(uint32 stage) const
{
StageInfo const* s = FindStage(stage);
return s ? s->description : "";
}
void Mgr::AppendLevelObjective(Payload& out, Player* player,
uint32 requiredLevel, uint32 order) const
{
Objective o;
o.id = order;
o.type = "level";
char buf[64];
std::snprintf(buf, sizeof(buf), "Достигнуть %u уровня",
requiredLevel);
o.title = buf;
o.progress = player->GetLevel();
o.required = requiredLevel;
o.completed = o.progress >= o.required;
o.targetId = requiredLevel;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendQuestObjective(Payload& out, Player* player,
uint32 questId,
std::string const& title,
uint32 order) const
{
QuestStatus st = player->GetQuestStatus(questId);
Objective o;
o.id = order;
o.type = "quest";
o.title = title;
o.required = 1;
o.progress = (st == QUEST_STATUS_REWARDED ||
st == QUEST_STATUS_COMPLETE) ? 1 : 0;
o.completed = (st == QUEST_STATUS_REWARDED);
o.targetId = questId;
o.questId = questId;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendDungeonObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const
{
auto it = _ejByMapId.find(mapId);
if (it == _ejByMapId.end())
return; // no EJ entry -> skip rather than emit a stub.
Objective o;
o.id = order;
o.type = "dungeon";
o.title = it->second.name.empty() ? title : it->second.name;
o.required = 1;
o.targetId = it->second.ejId; // primary id for click
o.ejInstanceId = it->second.ejId; // JOURNALINSTANCE key
o.mapId = mapId; // WoW map id (auxiliary)
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendRaidObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const
{
auto it = _ejByMapId.find(mapId);
if (it == _ejByMapId.end())
return;
Objective o;
o.id = order;
o.type = "raid";
o.title = it->second.name.empty() ? title : it->second.name;
o.required = 1;
o.targetId = it->second.ejId;
o.ejInstanceId = it->second.ejId;
o.mapId = mapId;
o.order = order;
out.objectives.push_back(std::move(o));
}
void Mgr::AppendRecommendations(Payload& out, Player* player) const
{
uint8 level = player->GetLevel();
uint32 stage = out.stageId;
std::unordered_set<uint32> seen;
uint32 recId = 1;
for (DungeonRec const& d : DUNGEONS)
{
if (stage < d.stage || stage > d.maxStage)
continue;
if (level + 2 < d.minLevel || level > d.maxLevel + 1)
continue;
// Resolve EJ id from custom_ej_instance. If the EJ does
// not know about this map, we cannot link to a card, so
// we skip the recommendation entirely. This keeps the
// contract that recommendations.instanceID is always a
// valid EJ id.
auto it = _ejByMapId.find(d.mapId);
if (it == _ejByMapId.end())
continue;
if (!seen.insert(it->second.ejId).second)
continue;
Recommendation r;
r.id = recId++;
r.type = "dungeon";
r.title = it->second.name.empty() ? d.title
: it->second.name;
r.ejInstanceId = it->second.ejId; // JOURNALINSTANCE key
r.mapId = d.mapId; // WoW map id (auxiliary)
r.priority = 100 -
static_cast<uint32>(std::abs(int32(level) -
int32((d.minLevel + d.maxLevel) / 2)));
r.description = "Подходит для вашего текущего этапа.";
out.recommendations.push_back(std::move(r));
if (out.recommendations.size() >= 6)
break;
}
}
static uint32 ProgressFor(uint32 stage, uint8 level)
{
// Rough %: scaled across the three expansion bands.
uint32 p = 0;
if (stage < PROGRESSION_PRE_TBC)
p = std::min<uint32>(60u, (level * 60u) / 60u);
else if (stage < PROGRESSION_WOTLK_TIER_1)
p = 60u + std::min<uint32>(20u,
((level > 60 ? level - 60 : 0) * 20u) / 10u);
else
p = 80u + std::min<uint32>(20u,
((level > 70 ? level - 70 : 0) * 20u) / 10u);
// Add a small bump per cleared major raid stage above 1.
if (stage > 1)
p = std::min<uint32>(100u, p + (stage - 1) * 2u);
return p;
}
Payload Mgr::BuildFor(Player* player) const
{
Payload out;
out.version = PAYLOAD_VERSION;
out.characterGuid = player->GetGUID().GetRawValue();
// ProgressionState as set by IPP. Default is 0 (start of vanilla).
uint32 ipStage = player->GetPlayerSetting(
"mod-individual-progression", SETTING_PROGRESSION_STATE).value;
// Player's *next* stage is what the guide should be helping with.
// STAGES use 1-based ids where stage id = ProgressionState+1.
out.stageId = std::min<uint32>(ipStage + 1, 19);
out.stageName = StageName(out.stageId);
out.stageDescription = StageDescription(out.stageId);
out.progress = ProgressFor(ipStage, player->GetLevel());
uint32 order = 1;
switch (ipStage)
{
case PROGRESSION_START:
AppendLevelObjective(out, player, 60, order++);
AppendRaidObjective(out, 409,
"Победить Рагнароса в Огненных Недрах", order++);
break;
case PROGRESSION_MOLTEN_CORE:
AppendLevelObjective(out, player, 60, order++);
AppendRaidObjective(out, 249,
"Победить Ониксию", order++);
AppendRaidObjective(out, 469,
"Победить Нефариана в Логове Крыла Тьмы", order++);
break;
case PROGRESSION_ONYXIA:
AppendRaidObjective(out, 469,
"Победить Нефариана в Логове Крыла Тьмы", order++);
AppendRaidObjective(out, 309,
"Зул'Гуруб (опционально)", order++);
break;
case PROGRESSION_BLACKWING_LAIR:
AppendQuestObjective(out, player, MIGHT_OF_KALIMDOR,
"Сдать ресурсы для военного похода", order++);
AppendQuestObjective(out, player, BANG_A_GONG,
"Открыть ворота Ан'Киража", order++);
break;
case PROGRESSION_PRE_AQ:
AppendQuestObjective(out, player, CHAOS_AND_DESTRUCTION,
"Завершить полевые задания в Силитусе", order++);
AppendRaidObjective(out, 509,
"Руины Ан'Киража", order++);
break;
case PROGRESSION_AQ_WAR:
AppendRaidObjective(out, 531,
"Победить Ц'Туна в Храме Ан'Киража", order++);
break;
case PROGRESSION_AQ:
AppendRaidObjective(out, 533,
"Победить КелТузада в Наксрамасе", order++);
break;
case PROGRESSION_NAXX40:
AppendQuestObjective(out, player, INTO_THE_BREACH,
"Открыть Тёмный портал", order++);
AppendLevelObjective(out, player, 70, order++);
break;
case PROGRESSION_PRE_TBC:
AppendLevelObjective(out, player, 70, order++);
AppendRaidObjective(out, 532,
"Каражан", order++);
AppendRaidObjective(out, 565,
"Логово Груула", order++);
AppendRaidObjective(out, 544,
"Логово Магтеридона", order++);
break;
case PROGRESSION_TBC_TIER_1:
AppendRaidObjective(out, 548,
"Пещера Змеиных Недр", order++);
AppendRaidObjective(out, 550,
"Око Бури — Крепость Бурь", order++);
break;
case PROGRESSION_TBC_TIER_2:
AppendRaidObjective(out, 534,
"Битва за гору Хиджал", order++);
AppendRaidObjective(out, 564,
"Чёрный храм", order++);
break;
case PROGRESSION_TBC_TIER_3:
AppendRaidObjective(out, 568,
"Зул'Аман", order++);
break;
case PROGRESSION_TBC_TIER_4:
AppendRaidObjective(out, 580,
"Плато Солнечного Колодца", order++);
break;
case PROGRESSION_TBC_TIER_5:
AppendLevelObjective(out, player, 80, order++);
AppendRaidObjective(out, 533,
"Наксрамас (Нордскол)", order++);
AppendRaidObjective(out, 616,
"Око Вечности", order++);
AppendRaidObjective(out, 615,
"Камера Сокровищ Обсидиановой драконьей стаи",
order++);
break;
case PROGRESSION_WOTLK_TIER_1:
AppendRaidObjective(out, 603,
"Победить Йогг-Сарона в Ульдуаре", order++);
break;
case PROGRESSION_WOTLK_TIER_2:
AppendRaidObjective(out, 649,
"Испытание крестоносца", order++);
break;
case PROGRESSION_WOTLK_TIER_3:
AppendRaidObjective(out, 631,
"Цитадель Ледяной Короны", order++);
break;
case PROGRESSION_WOTLK_TIER_4:
AppendRaidObjective(out, 724,
"Рубиновое святилище", order++);
break;
default:
break;
}
AppendRecommendations(out, player);
return out;
}
std::string Mgr::SerializeJson(Objective const& o) const
{
std::ostringstream s;
s << '{';
bool first = true;
WriteUInt(s, "id", o.id, first);
WriteString(s, "type", o.type, first);
WriteString(s, "title", o.title, first);
if (!o.description.empty())
WriteString(s, "description", o.description, first);
WriteBool(s, "completed", o.completed, first);
WriteUInt(s, "progress", o.progress, first);
WriteUInt(s, "required", o.required, first);
WriteUInt(s, "targetID", o.targetId, first);
WriteUInt(s, "order", o.order, first);
WriteOptUInt(s, "questID", o.questId, first);
WriteOptUInt(s, "questChainID", o.questChainId, first);
WriteOptUInt(s, "ej_instanceID", o.ejInstanceId, first);
WriteOptUInt(s, "mapID", o.mapId, first);
WriteOptUInt(s, "encounterID", o.encounterId, first);
WriteOptUInt(s, "achievementID", o.achievementId, first);
WriteOptUInt(s, "factionID", o.factionId, first);
WriteOptUInt(s, "professionID", o.professionId, first);
WriteOptUInt(s, "itemID", o.itemId, first);
WriteOptUInt(s, "currencyID", o.currencyId, first);
s << '}';
return s.str();
}
std::string Mgr::SerializeJson(Payload const& p) const
{
std::ostringstream s;
s << '{';
s << "\"version\":" << p.version
<< ",\"characterGuid\":" << p.characterGuid
<< ",\"stageID\":" << p.stageId
<< ",\"stageName\":\"" << EscapeJson(p.stageName) << '"'
<< ",\"stageDescription\":\""
<< EscapeJson(p.stageDescription) << '"'
<< ",\"progress\":" << std::min<uint32>(p.progress, 100u);
// Objectives: collapse duplicate ids and clamp progress.
s << ",\"objectives\":[";
std::set<uint32> seenIds;
bool firstObj = true;
for (Objective const& src : p.objectives)
{
if (!seenIds.insert(src.id).second)
continue;
Objective o = src;
if (o.required && o.progress > o.required)
o.progress = o.required;
if (!firstObj) s << ',';
s << SerializeJson(o);
firstObj = false;
}
s << "]";
// Recommendations.
s << ",\"recommendations\":[";
bool firstRec = true;
for (Recommendation const& r : p.recommendations)
{
if (!firstRec) s << ',';
s << '{';
bool f = true;
WriteUInt(s, "id", r.id, f);
WriteString(s, "type", r.type, f);
WriteString(s, "title", r.title, f);
if (!r.description.empty())
WriteString(s, "description", r.description, f);
WriteOptUInt(s, "ej_instanceID", r.ejInstanceId, f);
WriteOptUInt(s, "mapID", r.mapId, f);
WriteOptUInt(s, "encounterID", r.encounterId, f);
WriteOptUInt(s, "questID", r.questId, f);
WriteOptUInt(s, "itemID", r.itemId, f);
WriteUInt(s, "priority", r.priority, f);
s << '}';
firstRec = false;
}
s << "]";
// Rewards.
s << ",\"rewards\":[";
bool firstRew = true;
for (Reward const& r : p.rewards)
{
if (!firstRew) s << ',';
s << '{';
bool f = true;
WriteString(s, "type", r.type, f);
WriteOptUInt(s, "itemID", r.itemId, f);
WriteOptUInt(s, "currencyID", r.currencyId, f);
WriteUInt(s, "count", std::max<uint32>(r.count, 1), f);
WriteOptUInt(s, "amount", r.amount, f);
s << '}';
firstRew = false;
}
s << "]";
s << '}';
return s.str();
}
}
@@ -0,0 +1,159 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#ifndef MOONWELL_PLAYER_GUIDE_MGR_H
#define MOONWELL_PLAYER_GUIDE_MGR_H
#include "Define.h"
#include <cstdint>
#include <string>
#include <unordered_map>
#include <vector>
class Player;
namespace MoonWell::PlayerGuide
{
enum : uint32 { PAYLOAD_VERSION = 1 };
struct Objective
{
uint32 id = 0;
std::string type; // level | quest | quest_chain | dungeon |
// raid | boss | achievement |
// reputation | profession | item |
// currency | custom
std::string title;
std::string description;
bool completed = false;
uint32 progress = 0;
uint32 required = 0;
uint32 targetId = 0;
uint32 order = 0;
// Optional type-specific helpers. Zero means "not set" and
// serialiser omits it.
uint32 questId = 0;
uint32 questChainId = 0;
// ej_instanceID: key into client's JOURNALINSTANCE table.
// Consumed by EncounterJournal_DisplayInstance / EJ_SelectInstance.
uint32 ejInstanceId = 0;
// WoW map id (for telemetry / teleport / fallback). NOT used by
// the client to open EJ.
uint32 mapId = 0;
uint32 encounterId = 0;
uint32 achievementId = 0;
uint32 factionId = 0;
uint32 professionId = 0;
uint32 itemId = 0;
uint32 currencyId = 0;
};
struct Recommendation
{
uint32 id = 0;
std::string type;
std::string title;
std::string description;
uint32 ejInstanceId = 0; // JOURNALINSTANCE key
uint32 mapId = 0; // WoW map id (auxiliary)
uint32 encounterId = 0;
uint32 questId = 0;
uint32 itemId = 0;
uint32 priority = 0;
};
struct Reward
{
std::string type;
uint32 itemId = 0;
uint32 count = 1;
uint32 currencyId = 0;
uint32 amount = 0;
};
struct Payload
{
uint32 version = PAYLOAD_VERSION;
uint64 characterGuid = 0;
uint32 stageId = 0;
std::string stageName;
std::string stageDescription;
uint32 progress = 0; // 0..100
std::vector<Objective> objectives;
std::vector<Recommendation> recommendations;
std::vector<Reward> rewards;
};
class Mgr
{
public:
static Mgr* instance();
// Reads configuration. Safe to call multiple times.
void LoadConfig();
// Builds the per-character guide payload from IPP state.
// The function is read-only with respect to the player.
Payload BuildFor(Player* player) const;
// Serialises a payload to a single JSON string.
// Validates uniqueness of objective ids and clamps progress.
std::string SerializeJson(Payload const& payload) const;
std::string SerializeJson(Objective const& objective) const;
bool IsEnabled() const { return _enabled; }
bool LogPayloads() const { return _logPayloads; }
uint32 ChunkSize() const { return _chunkSize; }
// Reloads (map_id -> ej_instance.id/name) cache from
// custom_ej_instance. Called from LoadConfig() and from the
// OnLoadCustomDatabaseTable world hook.
void ReloadEncounterJournalCache();
private:
Mgr() = default;
// Stage helpers based on ProgressionState.
std::string StageName(uint32 stage) const;
std::string StageDescription(uint32 stage) const;
// Filling helpers.
void AppendLevelObjective(Payload& out, Player* player,
uint32 requiredLevel, uint32 order) const;
void AppendQuestObjective(Payload& out, Player* player,
uint32 questId, std::string const& title,
uint32 order) const;
void AppendDungeonObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const;
void AppendRaidObjective(Payload& out, uint32 mapId,
std::string const& title,
uint32 order) const;
void AppendRecommendations(Payload& out, Player* player) const;
struct EjInstanceRef
{
uint32 ejId = 0; // custom_ej_instance.id
std::string name; // custom_ej_instance.name
};
// mapId -> EJ instance ref. Filled at config load.
std::unordered_map<uint32, EjInstanceRef> _ejByMapId;
bool _enabled = true;
bool _logPayloads = false;
uint32 _chunkSize = 230; // per-frame body capacity (addon channel)
};
}
#define sPlayerGuideMgr ::MoonWell::PlayerGuide::Mgr::instance()
#endif // MOONWELL_PLAYER_GUIDE_MGR_H
@@ -0,0 +1,199 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#include "PlayerGuideMgr.h"
#include "PlayerGuideTransport.h"
#include "../IndividualProgression.h"
#include "Chat.h"
#include "Config.h"
#include "Creature.h"
#include "Log.h"
#include "Player.h"
#include "QuestDef.h"
#include "ScriptMgr.h"
#include "SharedDefines.h"
#include "WorldSession.h"
#include <cstdlib>
#include <string>
namespace MoonWell::PlayerGuide
{
namespace
{
void Push(Player* player)
{
if (!sPlayerGuideMgr->IsEnabled())
return;
if (!player || !player->GetSession() || player->IsBeingTeleported())
return;
if (player->GetSession()->IsBot())
return;
Payload p = sPlayerGuideMgr->BuildFor(player);
std::string json = sPlayerGuideMgr->SerializeJson(p);
if (sPlayerGuideMgr->LogPayloads())
LOG_DEBUG("module", "[PlayerGuide] push {} bytes -> {}",
json.size(), player->GetName());
Transport::SendSetData(player, json);
}
// Splits "MWPG\t<cmd>\t<arg>" -> (cmd, arg). Returns false if
// the message does not match the expected addon prefix.
bool ParseClientMessage(std::string const& in, std::string& cmd,
std::string& arg)
{
// Expected prefix: "MWPG\t".
if (in.size() < 6)
return false;
if (in.compare(0, 4, ADDON_PREFIX) != 0)
return false;
if (in[4] != '\t')
return false;
std::size_t end = in.find('\t', 5);
if (end == std::string::npos)
{
cmd.assign(in, 5, in.size() - 5);
arg.clear();
return true;
}
cmd.assign(in, 5, end - 5);
arg.assign(in, end + 1, in.size() - (end + 1));
return true;
}
}
class PlayerGuide_PlayerScript : public PlayerScript
{
public:
PlayerGuide_PlayerScript()
: PlayerScript("PlayerGuide_PlayerScript",
{
PLAYERHOOK_ON_LOGIN,
PLAYERHOOK_ON_LEVEL_CHANGED,
PLAYERHOOK_ON_PLAYER_COMPLETE_QUEST,
PLAYERHOOK_ON_CREATURE_KILL,
PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE
}) { }
void OnPlayerLogin(Player* player) override
{
Push(player);
}
void OnPlayerLevelChanged(Player* player, uint8 /*oldLevel*/) override
{
Push(player);
}
void OnPlayerCompleteQuest(Player* player,
Quest const* /*quest*/) override
{
Push(player);
}
void OnPlayerCreatureKill(Player* killer, Creature* killed) override
{
if (!killer || !killed)
return;
if (killed->GetCreatureTemplate()->rank <= CREATURE_ELITE_NORMAL)
return;
Push(killer);
}
void OnPlayerBeforeSendChatMessage(Player* player, uint32& type,
uint32& lang, std::string& msg) override
{
if (!sPlayerGuideMgr->IsEnabled())
return;
if (type != CHAT_MSG_WHISPER || lang != LANG_ADDON)
return;
if (sPlayerGuideMgr->LogPayloads())
LOG_DEBUG("module",
"[PlayerGuide] raw addon <- {} ({} bytes): '{}'",
player->GetName(), msg.size(), msg);
std::string cmd, arg;
if (!ParseClientMessage(msg, cmd, arg))
return;
LOG_INFO("module",
"[PlayerGuide] <- {} cmd='{}' arg='{}' ({} bytes)",
player->GetName(), cmd, arg, msg.size());
if (cmd == ClientCmd::REQUEST_REFRESH)
{
Push(player);
}
else if (cmd == ClientCmd::REQUEST_TRACK_OBJECTIVE)
{
// Tracking is purely client-side state for now; we just
// re-send the payload so the client can update its
// tracked id atomically.
Push(player);
}
else if (cmd == ClientCmd::REQUEST_OPEN_TARGET)
{
// Reserved for future: server-driven open-target action
// (e.g. queue LFG). For MVP a refresh is enough.
Push(player);
}
else
{
LOG_WARN("module",
"[PlayerGuide] unknown cmd='{}' from {}",
cmd, player->GetName());
return;
}
// Swallow the message so it does not get relayed as a real
// whisper. Clearing the body is enough — ChatHandler
// returns early on empty addon messages.
msg.clear();
}
};
class PlayerGuide_WorldScript : public WorldScript
{
public:
PlayerGuide_WorldScript()
: WorldScript("PlayerGuide_WorldScript",
{
WORLDHOOK_ON_AFTER_CONFIG_LOAD,
WORLDHOOK_ON_LOAD_CUSTOM_DATABASE_TABLE
}) { }
void OnAfterConfigLoad(bool /*reload*/) override
{
sPlayerGuideMgr->LoadConfig();
LOG_INFO("server.loading",
">> PlayerGuide module: {}.",
sPlayerGuideMgr->IsEnabled() ? "enabled" : "disabled");
}
void OnLoadCustomDatabaseTable() override
{
// Re-read custom_ej_instance after the EJ loader has had
// a chance to populate it. Order between modules on this
// hook is undefined, but we are reading the table itself
// (not EJ's in-memory cache) so we are fine as long as
// the SQL has been applied.
sPlayerGuideMgr->ReloadEncounterJournalCache();
}
};
}
void AddSC_mod_individual_progression_player_guide()
{
new MoonWell::PlayerGuide::PlayerGuide_PlayerScript();
new MoonWell::PlayerGuide::PlayerGuide_WorldScript();
}
@@ -0,0 +1,123 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#include "PlayerGuideTransport.h"
#include "PlayerGuideMgr.h"
#include "Chat.h"
#include "Player.h"
#include "WorldPacket.h"
#include "WorldSession.h"
#include <algorithm>
#include <cstdio>
#include <vector>
namespace MoonWell::PlayerGuide
{
namespace
{
// The client-visible chat body must stay strictly below the
// 255-byte addon limit. We reserve room for the framing header.
// 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)
{
std::string msg;
msg.reserve(ADDON_PREFIX[0] ? body.size() + 32 : body.size());
msg.append(ADDON_PREFIX);
msg.push_back('\t');
msg.append(command.data(), command.size());
msg.push_back('\t');
char buf[24];
std::snprintf(buf, sizeof(buf), "%u\t%u\t", seq, total);
msg.append(buf);
msg.append(body.data(), body.size());
if (msg.size() > HARD_LIMIT)
msg.resize(HARD_LIMIT);
WorldPacket data;
ChatHandler::BuildChatPacket(data, CHAT_MSG_WHISPER,
LANG_ADDON, player, player, msg);
player->GetSession()->SendPacket(&data);
}
}
void Transport::Send(Player* player, std::string_view command,
std::string_view body)
{
if (!player || !player->GetSession())
return;
// How much body fits into a chunk after framing.
// Framing = prefix(4) + '\t' + cmd(N) + '\t' + "65535\t65535\t".
// Use Mgr's configured chunk size when sensible.
std::size_t framing = 4 + 1 + command.size() + 1 + 16;
std::size_t budget = sPlayerGuideMgr->ChunkSize() > framing
? (sPlayerGuideMgr->ChunkSize() - framing)
: 64;
if (body.size() <= budget)
{
SendOneChunk(player, command, 1, 1, body);
return;
}
// 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 = 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]);
}
}
void Transport::SendSetData(Player* player, std::string const& json)
{
Send(player, ServerCmd::SET_DATA, json);
}
void Transport::SendNotify(Player* player, std::string const& text)
{
Send(player, ServerCmd::NOTIFY, text);
}
}
@@ -0,0 +1,57 @@
/*
* Copyright (C) 2016+ AzerothCore <www.azerothcore.org>,
* released under GNU AGPL v3 license:
* https://github.com/azerothcore/azerothcore-wotlk/blob/master/LICENSE-AGPL3
*/
#ifndef MOONWELL_PLAYER_GUIDE_TRANSPORT_H
#define MOONWELL_PLAYER_GUIDE_TRANSPORT_H
#include "Define.h"
#include <string>
#include <string_view>
class Player;
namespace MoonWell::PlayerGuide
{
// Addon channel prefix used on both ends. The client AIO bridge
// routes messages with this prefix to the MoonWellPlayerGuide
// handler.
constexpr char const* ADDON_PREFIX = "MWPG";
// Commands sent server -> client.
namespace ServerCmd
{
constexpr char const* SET_DATA = "SetData";
constexpr char const* SET_STAGE = "SetStage";
constexpr char const* UPDATE_OBJECTIVE = "UpdateObjective";
constexpr char const* NOTIFY = "Notify";
}
// Commands sent client -> server.
namespace ClientCmd
{
constexpr char const* REQUEST_REFRESH = "RequestRefresh";
constexpr char const* REQUEST_TRACK_OBJECTIVE = "RequestTrackObjective";
constexpr char const* REQUEST_OPEN_TARGET = "RequestOpenTarget";
}
class Transport
{
public:
// Sends a single logical message split into addon-channel
// chunks. Wire format per chunk:
// "MWPG\t<cmd>\t<seq>\t<total>\t<body>"
// Single-chunk messages still use seq=1 total=1.
static void Send(Player* player, std::string_view command,
std::string_view body);
// Convenience wrappers.
static void SendSetData(Player* player, std::string const& json);
static void SendNotify(Player* player, std::string const& text);
};
}
#endif // MOONWELL_PLAYER_GUIDE_TRANSPORT_H
+114
View File
@@ -0,0 +1,114 @@
# MoonWell Playerbots AI
This module sends a local `/say` or public-channel message to the LLM only when
a real player mentions the complete name of an online bot. A channel bot must
be a channel member; a `/say` bot must be within the server's normal listening
range. HTTP work runs outside the AzerothCore world thread.
Alice AI LLM Flash can classify direct natural-language requests into a small
allowlisted set of native playerbots commands. The server validates the command
again and executes it only against the addressed bot; existing playerbots
ownership and group security checks still apply.
The routing prompt covers party movement and combat, LFG, combat/non-combat
strategies, spells, RTI/RTSC, focus healing, loot lists, inventory operations,
quests, pets, talents, glyphs, maintenance, and information commands documented
in the upstream Playerbot Commands wiki. Administrative, console, account,
debug/cheat, and destructive `destroy`/`drop`/mail commands are intentionally
not executable through the LLM.
Common Russian movement/combat phrases use a deterministic fast path. Other
requests first pass through a command-only classifier; ordinary conversation is
then sent separately to the roleplay prompt. While AI routing is enabled, the
legacy public-channel parser is bypassed and `setup-modules.sh` also forces
`AiPlayerbot.RandomBotTalk = 0`. RandomBotTalk consumes incoming chat packets
inside each bot, so bypassing only the parser does not prevent its delayed
stock replies. Whisper, party, raid, and guild command hooks remain unchanged.
Recognized commands produce a gender-aware local-chat acknowledgement
(`Понял команду.` or `Поняла команду.`) before they are handed to the native
playerbots command handler. Bot and player gender are also included in the LLM
context for pronoun, past-tense verb, and adjective agreement. Russian requests
to show bags or inventory use the native `inv` command; `open items` is reserved
for actually opening container items.
Trade and quest requests have deterministic link-preserving routes before the
LLM classifier:
- `дай/передай [item]` -> `t [item]`; `начни обмен` -> `t`
- `покажи задания` -> `quests all`; completed/incomplete filters are supported
- `покажи прогресс [quest]` -> `q [quest]`
- `прими [quest]` / `прими все задания` -> `accept [quest]` / `accept *`
- `поделись [quest]` -> `share [quest]`
- `выбери [item] в награду` -> `r [item]`
- `сдай готовое задание` -> `talk`
- `откажись от [quest]` -> `drop [quest]` only with an explicit quest link
These routes preserve the original WoW hyperlink byte-for-byte. The local
playerbots trade action continues processing after it creates the trade, so one
`t [item]` command both initiates the exchange and places the requested item in
the trade window.
The bundled `playerbots-ai` bridge uses the Yandex AI Studio OpenAI-compatible
Chat Completions API and defaults to Alice AI LLM Flash.
## Configuration
Set these values in the repository `.env`:
```dotenv
ACORE_PLAYERBOTS_AI_ENABLED=1
ACORE_PLAYERBOTS_AI_COMMANDS_ENABLED=1
YANDEX_AI_API_KEY=<service-account API key>
YANDEX_AI_FOLDER_ID=<Yandex Cloud folder ID>
```
Until the subscription system exists, public access is controlled by one
explicit switch:
```dotenv
ACORE_PLAYERBOTS_AI_AVAILABLE_FOR_ALL=1
```
`1` allows every player to reach the bridge. `0` denies LLM access to everyone
without spending tokens. When subscriptions are implemented, their entitlement
check will be added to the disabled branch instead of using a temporary manual
account list.
The command classifier has a persistent SQLite cache in the
`playerbots-ai-cache` Docker volume. The key excludes the addressed bot name,
so repeated phrases such as “покажи инвентарь” are shared across players and
bots. Both commands and “ordinary chat, not a command” decisions are cached.
The cache does not store roleplay responses or conversation history.
Obvious social phrases such as greetings, “как дела?” and “расскажи о себе”
skip the command classifier entirely: they still receive an Alice roleplay
reply, but do not pay the roughly 800-token command-catalog prompt first.
```dotenv
PLAYERBOTS_AI_CLASSIFIER_CACHE_ENABLED=1
PLAYERBOTS_AI_CLASSIFIER_CACHE_TTL_SECONDS=604800
PLAYERBOTS_AI_CLASSIFIER_CACHE_MAX_ENTRIES=10000
```
Changing the classifier prompt automatically creates a new cache namespace.
Old rows expire normally, and the oldest rows are removed at the size limit.
The default model URI is:
```text
gpt://<YANDEX_AI_FOLDER_ID>/aliceai-llm-flash
```
Run `./start-server.sh` or `scripts/prod-deploy.sh`. The deployment wrapper
builds and starts the bridge before `ac-worldserver`.
Never commit a populated API key.
Full Yandex response JSON is logged by default by the bridge:
```dotenv
PLAYERBOTS_AI_LOG_API_RESPONSES=1
```
Use `docker compose logs -f playerbots-ai` to follow classifier and chat
responses. Authentication headers and the API key are never logged.
@@ -0,0 +1,29 @@
#
# MoonWell Playerbots AI
#
# Enables LLM replies in public channels.
PlayerbotsAI.Enabled = 0
# Enables natural-language commands through an allowlisted playerbots command set.
PlayerbotsAI.CommandsEnabled = 1
# Temporary public-access switch. A future subscription check will be inserted
# when this is disabled; until that system exists, 0 denies access to everyone.
PlayerbotsAI.AvailableForAll = 1
# Internal HTTP bridge. The Yandex API key is kept in the bridge container.
PlayerbotsAI.BridgeHost = playerbots-ai
PlayerbotsAI.BridgePort = 8080
PlayerbotsAI.BridgePath = /v1/chat
# Per-player request cooldown and bounded worker queue.
PlayerbotsAI.PlayerCooldownSeconds = 8
PlayerbotsAI.MaxQueuedRequests = 32
# Maximum UTF-8 characters sent back to an in-game channel.
PlayerbotsAI.MaxReplyCharacters = 240
# Extra request/response logging. Prompts and API keys are never logged.
PlayerbotsAI.Debug = 0
п
+3
View File
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
# This module uses AzerothCore's standard source and loader discovery.
@@ -0,0 +1,808 @@
#include "Channel.h"
#include "ChannelMgr.h"
#include "Config.h"
#include "Language.h"
#include "Log.h"
#include "ObjectAccessor.h"
#include "Player.h"
#include "PlayerbotAI.h"
#include "PlayerbotMgr.h"
#include "RandomPlayerbotMgr.h"
#include "ScriptMgr.h"
#include "World.h"
#include <boost/asio/connect.hpp>
#include <boost/asio/ip/tcp.hpp>
#include <boost/asio/read.hpp>
#include <boost/asio/read_until.hpp>
#include <boost/asio/streambuf.hpp>
#include <boost/asio/write.hpp>
#include <algorithm>
#include <atomic>
#include <chrono>
#include <condition_variable>
#include <cstdint>
#include <deque>
#include <iomanip>
#include <map>
#include <mutex>
#include <optional>
#include <set>
#include <sstream>
#include <stdexcept>
#include <string>
#include <thread>
#include <utility>
#include <vector>
namespace
{
struct AiRequest
{
ObjectGuid botGuid;
ObjectGuid playerGuid;
TeamId teamId;
std::string channelName;
std::string host;
std::string port;
std::string path;
std::string body;
};
struct AiResponse
{
ObjectGuid botGuid;
ObjectGuid playerGuid;
TeamId teamId;
std::string channelName;
std::string command;
std::string text;
};
struct BridgeResponse
{
std::string command;
std::string text;
};
std::string JsonEscape(std::string const& value)
{
std::ostringstream output;
for (unsigned char character : value)
{
switch (character)
{
case '"': output << "\\\""; break;
case '\\': output << "\\\\"; break;
case '\b': output << "\\b"; break;
case '\f': output << "\\f"; break;
case '\n': output << "\\n"; break;
case '\r': output << "\\r"; break;
case '\t': output << "\\t"; break;
default:
if (character < 0x20)
{
output << "\\u" << std::hex << std::setw(4) << std::setfill('0')
<< static_cast<uint32>(character) << std::dec;
}
else
{
output << static_cast<char>(character);
}
break;
}
}
return output.str();
}
std::vector<uint32> DecodeAndCaseFold(std::string const& text)
{
std::vector<uint32> result;
result.reserve(text.size());
for (std::size_t index = 0; index < text.size();)
{
unsigned char first = static_cast<unsigned char>(text[index]);
uint32 codepoint = 0xFFFD;
std::size_t length = 1;
if (first < 0x80)
{
codepoint = first;
}
else if ((first & 0xE0) == 0xC0 && index + 1 < text.size())
{
codepoint = ((first & 0x1F) << 6)
| (static_cast<unsigned char>(text[index + 1]) & 0x3F);
length = 2;
}
else if ((first & 0xF0) == 0xE0 && index + 2 < text.size())
{
codepoint = ((first & 0x0F) << 12)
| ((static_cast<unsigned char>(text[index + 1]) & 0x3F) << 6)
| (static_cast<unsigned char>(text[index + 2]) & 0x3F);
length = 3;
}
else if ((first & 0xF8) == 0xF0 && index + 3 < text.size())
{
codepoint = ((first & 0x07) << 18)
| ((static_cast<unsigned char>(text[index + 1]) & 0x3F) << 12)
| ((static_cast<unsigned char>(text[index + 2]) & 0x3F) << 6)
| (static_cast<unsigned char>(text[index + 3]) & 0x3F);
length = 4;
}
if (codepoint >= 'A' && codepoint <= 'Z')
codepoint += 'a' - 'A';
else if (codepoint >= 0x0410 && codepoint <= 0x042F)
codepoint += 0x20;
else if (codepoint == 0x0401)
codepoint = 0x0451;
result.push_back(codepoint);
index += length;
}
return result;
}
bool IsWordCodepoint(uint32 codepoint)
{
return (codepoint >= '0' && codepoint <= '9')
|| (codepoint >= 'a' && codepoint <= 'z')
|| (codepoint >= 0x0430 && codepoint <= 0x044F)
|| codepoint == 0x0451;
}
std::optional<std::size_t> FindCompleteName(
std::vector<uint32> const& message,
std::vector<uint32> const& name)
{
if (name.empty() || name.size() > message.size())
return std::nullopt;
for (std::size_t position = 0; position + name.size() <= message.size(); ++position)
{
if (!std::equal(name.begin(), name.end(), message.begin() + position))
continue;
bool leftBoundary = position == 0 || !IsWordCodepoint(message[position - 1]);
std::size_t end = position + name.size();
bool rightBoundary = end == message.size() || !IsWordCodepoint(message[end]);
if (leftBoundary && rightBoundary)
return position;
}
return std::nullopt;
}
std::string NormalizeReply(std::string text, uint32 maxCharacters)
{
std::replace(text.begin(), text.end(), '\r', ' ');
std::replace(text.begin(), text.end(), '\n', ' ');
std::string compact;
compact.reserve(text.size());
bool previousSpace = false;
for (unsigned char character : text)
{
bool isSpace = character == ' ' || character == '\t';
if (isSpace && previousSpace)
continue;
compact.push_back(isSpace ? ' ' : static_cast<char>(character));
previousSpace = isSpace;
}
std::size_t first = compact.find_first_not_of(' ');
if (first == std::string::npos)
return {};
compact.erase(0, first);
std::size_t last = compact.find_last_not_of(' ');
compact.erase(last + 1);
if (!maxCharacters)
return compact;
std::size_t byteIndex = 0;
uint32 characters = 0;
while (byteIndex < compact.size() && characters < maxCharacters)
{
unsigned char firstByte = static_cast<unsigned char>(compact[byteIndex]);
std::size_t length = firstByte < 0x80 ? 1
: (firstByte & 0xE0) == 0xC0 ? 2
: (firstByte & 0xF0) == 0xE0 ? 3
: (firstByte & 0xF8) == 0xF0 ? 4
: 1;
byteIndex = std::min(compact.size(), byteIndex + length);
++characters;
}
if (byteIndex < compact.size())
{
compact.resize(byteIndex);
compact += "";
}
return compact;
}
BridgeResponse ParseBridgeResponse(std::string response)
{
static std::string const commandPrefix = "PB-COMMAND:";
if (response.rfind(commandPrefix, 0) != 0)
return {"", std::move(response)};
std::size_t newline = response.find('\n');
if (newline == std::string::npos)
return {"", ""};
return {
response.substr(commandPrefix.size(), newline - commandPrefix.size()),
response.substr(newline + 1)
};
}
bool IsAllowedPlayerbotCommand(std::string const& command)
{
if (command.empty() || command.size() > 300 || command.find('\\') != std::string::npos)
return false;
if (std::any_of(command.begin(), command.end(), [](unsigned char character)
{
return character < 0x20;
}))
{
return false;
}
static std::set<std::string> const allowedCommands = {
"accept",
"accept *",
"add all loot",
"attack",
"attack rti target",
"autogear",
"buff",
"disperse disable",
"flee",
"focus heal ?",
"focus heal clear",
"focus heal none",
"focus heal unset",
"follow",
"give leader",
"glyphs",
"grind",
"help",
"home",
"inv",
"invite",
"items",
"leave",
"lfg",
"ll all",
"ll gray",
"ll normal",
"ll quest",
"ll skill",
"los",
"maintenance",
"open items",
"outfit ?",
"pet aggressive",
"pet attack",
"pet defensive",
"pet follow",
"pet passive",
"pet stance",
"pet stay",
"quests",
"quests all",
"quests completed",
"quests incompleted",
"ready",
"release",
"reset",
"reset botAI",
"revive",
"roll",
"rti",
"rtsc",
"rtsc cancel",
"rtsc go save",
"rtsc toggle",
"runaway",
"spells",
"ss reset",
"stay",
"stats",
"summon",
"t",
"talents",
"talents spec list",
"talk",
"tame",
"tame family",
"tank attack",
"trainer",
"trainer learn",
"who"
};
if (allowedCommands.find(command) != allowedCommands.end())
return true;
static std::vector<std::string> const allowedPrefixes = {
"accept ",
"b ",
"bank ",
"cast ",
"co ",
"disperse set ",
"e ",
"focus heal ",
"gb ",
"glyph equip ",
"lfg ",
"ll ",
"nc ",
"outfit ",
"pet ",
"q ",
"r ",
"roll ",
"rti ",
"rtsc ",
"s ",
"ss ",
"share ",
"talents apply ",
"talents spec ",
"tame ",
"t ",
"drop ",
"u ",
"ue ",
"who "
};
return std::any_of(
allowedPrefixes.begin(),
allowedPrefixes.end(),
[&command](std::string const& prefix)
{
return command.size() > prefix.size() && command.rfind(prefix, 0) == 0;
});
}
std::string ReadHttpBody(
std::string const& host,
std::string const& port,
std::string const& path,
std::string const& body)
{
using boost::asio::ip::tcp;
boost::asio::io_context ioContext;
tcp::resolver resolver(ioContext);
tcp::socket socket(ioContext);
boost::asio::connect(socket, resolver.resolve(host, port));
std::ostringstream request;
request << "POST " << path << " HTTP/1.1\r\n"
<< "Host: " << host << ':' << port << "\r\n"
<< "User-Agent: MoonWell-playerbots-ai/1.0\r\n"
<< "Content-Type: application/json; charset=utf-8\r\n"
<< "Accept: text/plain\r\n"
<< "Connection: close\r\n"
<< "Content-Length: " << body.size() << "\r\n\r\n"
<< body;
boost::asio::write(socket, boost::asio::buffer(request.str()));
boost::asio::streambuf response;
boost::asio::read_until(socket, response, "\r\n\r\n");
std::istream responseStream(&response);
std::string httpVersion;
unsigned int statusCode = 0;
std::string statusMessage;
responseStream >> httpVersion >> statusCode;
std::getline(responseStream, statusMessage);
if (!responseStream || httpVersion.rfind("HTTP/", 0) != 0)
throw std::runtime_error("invalid HTTP response");
std::string header;
while (std::getline(responseStream, header) && header != "\r")
{
}
std::ostringstream responseBody;
if (response.size())
responseBody << &response;
boost::system::error_code error;
while (boost::asio::read(socket, response, boost::asio::transfer_at_least(1), error))
responseBody << &response;
if (error != boost::asio::error::eof)
throw boost::system::system_error(error);
if (response.size())
responseBody << &response;
if (statusCode < 200 || statusCode >= 300)
{
throw std::runtime_error(
"bridge returned HTTP " + std::to_string(statusCode) + ": " + responseBody.str());
}
return responseBody.str();
}
class PlayerbotsAiBridge
{
public:
static PlayerbotsAiBridge& Instance()
{
static PlayerbotsAiBridge instance;
return instance;
}
void Start()
{
bool expected = false;
if (!_running.compare_exchange_strong(expected, true))
return;
_stopping = false;
_worker = std::thread([this] { Run(); });
LOG_INFO("server.loading", "[PlayerbotsAI] Async bridge worker started");
}
void Stop()
{
if (!_running.exchange(false))
return;
{
std::lock_guard<std::mutex> lock(_requestMutex);
_stopping = true;
}
_requestReady.notify_all();
if (_worker.joinable())
_worker.join();
std::lock_guard<std::mutex> requestLock(_requestMutex);
_requests.clear();
LOG_INFO("server.loading", "[PlayerbotsAI] Async bridge worker stopped");
}
bool Submit(AiRequest request, uint32 maxQueuedRequests)
{
if (!_running)
return false;
std::lock_guard<std::mutex> lock(_requestMutex);
if (_stopping || _requests.size() >= maxQueuedRequests)
return false;
_requests.push_back(std::move(request));
_requestReady.notify_one();
return true;
}
std::deque<AiResponse> TakeResponses()
{
std::lock_guard<std::mutex> lock(_responseMutex);
std::deque<AiResponse> responses;
responses.swap(_responses);
return responses;
}
private:
PlayerbotsAiBridge() = default;
~PlayerbotsAiBridge()
{
Stop();
}
void Run()
{
while (true)
{
AiRequest request;
{
std::unique_lock<std::mutex> lock(_requestMutex);
_requestReady.wait(lock, [this] { return _stopping || !_requests.empty(); });
if (_stopping && _requests.empty())
break;
request = std::move(_requests.front());
_requests.pop_front();
}
try
{
BridgeResponse response = ParseBridgeResponse(ReadHttpBody(
request.host, request.port, request.path, request.body));
std::lock_guard<std::mutex> lock(_responseMutex);
_responses.push_back({
request.botGuid,
request.playerGuid,
request.teamId,
std::move(request.channelName),
std::move(response.command),
std::move(response.text)
});
}
catch (std::exception const& error)
{
LOG_ERROR("server.loading", "[PlayerbotsAI] Bridge request failed: {}", error.what());
}
}
}
std::atomic<bool> _running{false};
bool _stopping{false};
std::thread _worker;
std::mutex _requestMutex;
std::condition_variable _requestReady;
std::deque<AiRequest> _requests;
std::mutex _responseMutex;
std::deque<AiResponse> _responses;
};
bool IsEnabled()
{
return sConfigMgr->GetOption<bool>("PlayerbotsAI.Enabled", false);
}
bool IsAvailableForAll()
{
return sConfigMgr->GetOption<bool>("PlayerbotsAI.AvailableForAll", true);
}
AiRequest BuildRequest(
Player* bot,
Player* player,
std::string channelName,
std::string const& message)
{
std::ostringstream body;
body << '{'
<< "\"bot_guid\":\"" << bot->GetGUID().GetCounter() << "\","
<< "\"player_guid\":\"" << player->GetGUID().GetCounter() << "\","
<< "\"bot_name\":\"" << JsonEscape(bot->GetName()) << "\","
<< "\"player_name\":\"" << JsonEscape(player->GetName()) << "\","
<< "\"message\":\"" << JsonEscape(message) << "\","
<< "\"bot_gender\":" << static_cast<uint32>(bot->getGender()) << ','
<< "\"player_gender\":" << static_cast<uint32>(player->getGender()) << ','
<< "\"bot_class\":" << static_cast<uint32>(bot->getClass()) << ','
<< "\"bot_race\":" << static_cast<uint32>(bot->getRace()) << ','
<< "\"bot_level\":" << static_cast<uint32>(bot->GetLevel()) << ','
<< "\"zone_id\":" << bot->GetZoneId()
<< '}';
return {
bot->GetGUID(),
player->GetGUID(),
bot->GetTeamId(),
std::move(channelName),
sConfigMgr->GetOption<std::string>("PlayerbotsAI.BridgeHost", "playerbots-ai"),
std::to_string(sConfigMgr->GetOption<uint32>("PlayerbotsAI.BridgePort", 8080)),
sConfigMgr->GetOption<std::string>("PlayerbotsAI.BridgePath", "/v1/chat"),
body.str()
};
}
class PlayerbotsAiPlayerScript : public PlayerScript
{
public:
PlayerbotsAiPlayerScript()
: PlayerScript(
"PlayerbotsAiPlayerScript",
{
PLAYERHOOK_ON_BEFORE_SEND_CHAT_MESSAGE,
PLAYERHOOK_CAN_PLAYER_USE_CHANNEL_CHAT
})
{
}
void OnPlayerBeforeSendChatMessage(
Player* player,
uint32& type,
uint32& /*language*/,
std::string& message) override
{
if (type == CHAT_MSG_SAY)
HandleAddressedMessage(player, message, nullptr);
}
bool OnPlayerCanUseChat(
Player* player,
uint32 type,
uint32 /*language*/,
std::string& message,
Channel* channel) override
{
if (type == CHAT_MSG_CHANNEL)
HandleAddressedMessage(player, message, channel);
return true;
}
private:
void HandleAddressedMessage(Player* player, std::string const& message, Channel* channel)
{
if (!IsEnabled() || !player || message.empty())
return;
if (PlayerbotsMgr::instance().GetPlayerbotAI(player))
return;
std::vector<uint32> foldedMessage = DecodeAndCaseFold(message);
Player* selectedBot = nullptr;
std::size_t selectedPosition = foldedMessage.size();
PlayerBotMap bots = sRandomPlayerbotMgr.GetAllBots();
if (PlayerbotMgr* playerbotMgr = PlayerbotsMgr::instance().GetPlayerbotMgr(player))
{
for (auto iterator = playerbotMgr->GetPlayerBotsBegin();
iterator != playerbotMgr->GetPlayerBotsEnd();
++iterator)
{
bots.emplace(iterator->first, iterator->second);
}
}
for (auto const& [guid, bot] : bots)
{
if (!bot || !bot->IsInWorld())
continue;
if (channel)
{
if (!channel->HasMember(guid))
continue;
}
else if (!bot->IsWithinDistInMap(
player, sWorld->getFloatConfig(CONFIG_LISTEN_RANGE_SAY)))
{
continue;
}
std::optional<std::size_t> position =
FindCompleteName(foldedMessage, DecodeAndCaseFold(bot->GetName()));
if (position && *position < selectedPosition)
{
selectedBot = bot;
selectedPosition = *position;
}
}
if (!selectedBot)
return;
if (!IsAvailableForAll())
{
std::string const deniedReply = "Разговоры со мной через ИИ сейчас недоступны.";
if (channel)
channel->Say(selectedBot->GetGUID(), deniedReply, LANG_UNIVERSAL);
else
selectedBot->Say(deniedReply, LANG_UNIVERSAL);
return;
}
auto now = std::chrono::steady_clock::now();
uint32 cooldownSeconds =
sConfigMgr->GetOption<uint32>("PlayerbotsAI.PlayerCooldownSeconds", 8);
auto cooldown = std::chrono::seconds(cooldownSeconds);
auto found = _lastRequest.find(player->GetGUID());
if (found != _lastRequest.end() && now - found->second < cooldown)
return;
uint32 maxQueuedRequests =
std::max<uint32>(1, sConfigMgr->GetOption<uint32>("PlayerbotsAI.MaxQueuedRequests", 32));
if (!PlayerbotsAiBridge::Instance().Submit(
BuildRequest(
selectedBot,
player,
channel ? channel->GetName() : std::string(),
message),
maxQueuedRequests))
{
LOG_WARN("server.loading", "[PlayerbotsAI] Request queue is unavailable or full");
return;
}
_lastRequest[player->GetGUID()] = now;
if (sConfigMgr->GetOption<bool>("PlayerbotsAI.Debug", false))
{
LOG_INFO(
"server.loading",
"[PlayerbotsAI] {} addressed bot {} in {}",
player->GetName(),
selectedBot->GetName(),
channel ? channel->GetName() : "/say");
}
}
std::map<ObjectGuid, std::chrono::steady_clock::time_point> _lastRequest;
};
class PlayerbotsAiWorldScript : public WorldScript
{
public:
PlayerbotsAiWorldScript()
: WorldScript(
"PlayerbotsAiWorldScript",
{WORLDHOOK_ON_STARTUP, WORLDHOOK_ON_UPDATE, WORLDHOOK_ON_SHUTDOWN})
{
}
void OnStartup() override
{
if (IsEnabled())
PlayerbotsAiBridge::Instance().Start();
}
void OnUpdate(uint32 /*diff*/) override
{
if (!IsEnabled())
return;
uint32 maxCharacters =
sConfigMgr->GetOption<uint32>("PlayerbotsAI.MaxReplyCharacters", 240);
for (AiResponse& response : PlayerbotsAiBridge::Instance().TakeResponses())
{
Player* bot = ObjectAccessor::FindPlayer(response.botGuid);
if (!bot || !bot->IsInWorld() || !PlayerbotsMgr::instance().GetPlayerbotAI(bot))
continue;
Player* player = ObjectAccessor::FindPlayer(response.playerGuid);
if (!player || !player->IsInWorld())
continue;
Channel* channel = nullptr;
if (response.channelName.empty())
{
if (!bot->IsWithinDistInMap(
player, sWorld->getFloatConfig(CONFIG_LISTEN_RANGE_SAY)))
{
continue;
}
}
else
{
ChannelMgr* channelManager = ChannelMgr::forTeam(response.teamId);
channel = channelManager
? channelManager->GetChannel(response.channelName, bot, false)
: nullptr;
if (!channel || !channel->HasMember(bot->GetGUID()))
continue;
}
if (sConfigMgr->GetOption<bool>("PlayerbotsAI.CommandsEnabled", true)
&& IsAllowedPlayerbotCommand(response.command))
{
PlayerbotAI* botAi = PlayerbotsMgr::instance().GetPlayerbotAI(bot);
botAi->HandleCommand(CHAT_MSG_WHISPER, response.command, player);
}
std::string reply = NormalizeReply(std::move(response.text), maxCharacters);
if (!reply.empty())
{
if (channel)
channel->Say(bot->GetGUID(), reply, LANG_UNIVERSAL);
else
bot->Say(reply, LANG_UNIVERSAL);
}
}
}
void OnShutdown() override
{
PlayerbotsAiBridge::Instance().Stop();
}
};
}
void AddSC_mod_playerbots_ai()
{
new PlayerbotsAiPlayerScript();
new PlayerbotsAiWorldScript();
}
@@ -0,0 +1,6 @@
void AddSC_mod_playerbots_ai();
void Addmod_playerbots_aiScripts()
{
AddSC_mod_playerbots_ai();
}
@@ -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
@@ -55,7 +55,7 @@ bool AcceptInvitationAction::Execute(Event event)
botAI->ChangeStrategy("+follow,-lfg,-bg", BOT_STATE_NON_COMBAT);
botAI->Reset();
botAI->TellMaster("Hello");
botAI->TellMaster("Здравствуйте.");
if (sPlayerbotAIConfig.summonWhenGroup && bot->GetDistance(inviter) > sPlayerbotAIConfig.sightDistance)
{
@@ -18,7 +18,7 @@ bool AcceptAllQuestsAction::ProcessQuest(Quest const* quest, Object* questGiver)
if (botAI->HasStrategy("debug quest", BotState::BOT_STATE_NON_COMBAT) || botAI->HasStrategy("debug rpg", BotState::BOT_STATE_COMBAT))
{
LOG_INFO("playerbots", "{} => Quest [{}] accepted", bot->GetName(), quest->GetTitle());
bot->Say("Quest [" + text_quest + "] accepted", LANG_UNIVERSAL);
bot->Say("Задание [" + text_quest + "] accepted", LANG_UNIVERSAL);
}
return true;
@@ -113,7 +113,7 @@ bool AcceptQuestShareAction::Execute(Event event)
if (bot->HasQuest(quest))
{
bot->SetDivider(ObjectGuid::Empty);
botAI->TellError("I have this quest");
botAI->TellError("У меня уже есть это задание");
return false;
}
@@ -121,7 +121,7 @@ bool AcceptQuestShareAction::Execute(Event event)
{
// can't take quest
bot->SetDivider(ObjectGuid::Empty);
botAI->TellError("I can't take this quest");
botAI->TellError("Я не могу взять это задание");
return false;
}
@@ -149,7 +149,7 @@ bool AcceptQuestShareAction::Execute(Event event)
bot->CastSpell(bot, qInfo->GetSrcSpell(), true);
}
botAI->TellMaster("Quest accepted");
botAI->TellMaster("Задание принято.");
return true;
}
@@ -36,7 +36,7 @@ bool ReachAreaTriggerAction::Execute(Event event)
if (bot->GetMapId() != at->map)
{
botAI->TellError("I won't follow: too far away");
botAI->TellError("Я не последую за вами: вы слишком далеко");
return true;
}
@@ -51,7 +51,7 @@ bool ReachAreaTriggerAction::Execute(Event event)
float distance = bot->GetDistance(at->x, at->y, at->z);
float delay = 1000.0f * distance / bot->GetSpeed(MOVE_RUN) + sPlayerbotAIConfig.reactDelay;
botAI->TellError("Wait for me");
botAI->TellError("Подождите меня");
botAI->SetNextCheckDelay(delay);
context->GetValue<LastMovement&>("last area trigger")->Get().lastAreaTrigger = triggerId;
@@ -76,6 +76,6 @@ bool AreaTriggerAction::Execute(Event /*event*/)
p.rpos(0);
bot->GetSession()->HandleAreaTriggerOpcode(p);
botAI->TellMaster("Hello");
botAI->TellMaster("Здравствуйте.");
return true;
}
@@ -31,7 +31,7 @@ bool ArenaTeamAcceptAction::Execute(Event event)
if (bot->GetArenaTeamId(at->GetSlot()))
{
// bot is already in an arena team
bot->Say("Sorry, I am already in such team", LANG_UNIVERSAL);
bot->Say("У меня уже есть такая команда", LANG_UNIVERSAL);
accept = false;
}
@@ -39,7 +39,7 @@ bool ArenaTeamAcceptAction::Execute(Event event)
{
WorldPacket data(CMSG_ARENA_TEAM_ACCEPT);
bot->GetSession()->HandleArenaTeamAcceptOpcode(data);
bot->Say("Thanks for the invite!", LANG_UNIVERSAL);
bot->Say("Спасибо за приглашение!", LANG_UNIVERSAL);
LOG_INFO("playerbots", "Bot {} <{}> accepts Arena Team invite", bot->GetGUID().ToString().c_str(),
bot->GetName().c_str());
return true;
@@ -37,7 +37,7 @@ bool AttackMyTargetAction::Execute(Event /*event*/)
if (!guid)
{
if (verbose)
botAI->TellError("You have no target");
botAI->TellError("У вас нет цели");
return false;
}
@@ -63,7 +63,7 @@ bool AttackAction::Attack(Unit* target, bool /*with_pet*/ /*true*/)
bot->HasUnitState(UNIT_STATE_IN_FLIGHT))
{
if (verbose)
botAI->TellError("I cannot attack in flight");
botAI->TellError("Я не могу атаковать в полёте");
return false;
}
@@ -71,7 +71,7 @@ bool AttackAction::Attack(Unit* target, bool /*with_pet*/ /*true*/)
if (!target)
{
if (verbose)
botAI->TellError("I have no target");
botAI->TellError("У меня нет цели");
return false;
}
@@ -91,7 +91,7 @@ bool AttackAction::Attack(Unit* target, bool /*with_pet*/ /*true*/)
sPlayerbotAIConfig.IsPvpProhibited(target->GetZoneId(), target->GetAreaId())))
{
if (verbose)
botAI->TellError("I cannot attack other players in PvP prohibited areas.");
botAI->TellError("Я не могу атаковать игроков в зонах, где запрещено PvP.");
return false;
}
@@ -123,7 +123,7 @@ bool AttackAction::Attack(Unit* target, bool /*with_pet*/ /*true*/)
if (sameTarget && inCombat && sameAttackMode)
{
if (verbose)
botAI->TellError("I am already attacking " + std::string(target->GetName()) + ".");
botAI->TellError("Я уже атакую: " + std::string(target->GetName()) + ".");
return false;
}
@@ -131,7 +131,7 @@ bool AttackAction::Attack(Unit* target, bool /*with_pet*/ /*true*/)
if (!bot->IsValidAttackTarget(target))
{
if (verbose)
botAI->TellError("I cannot attack an invalid target.");
botAI->TellError("Я не могу атаковать недопустимую цель.");
return false;
}
@@ -54,7 +54,7 @@ void AutoMaintenanceOnLevelupAction::AutoLearnSpell()
{
std::string const temp = out.str();
out.seekp(0);
out << "Learned spells: ";
out << "Изученные заклинания: ";
out << temp;
out.seekp(-2, out.cur);
out << ".";
@@ -23,7 +23,7 @@ bool BankAction::Execute(Event event)
return ExecuteBank(text, npc);
}
botAI->TellError("Cannot find banker nearby");
botAI->TellError("Поблизости нет банкира");
return false;
}
@@ -82,7 +82,7 @@ bool BankAction::Withdraw(uint32 itemid)
bot->StoreItem(dest, pItem, true);
std::ostringstream out;
out << "got " << chat->FormatItem(pItem->GetTemplate(), pItem->GetCount()) << " from bank";
out << "получено: " << chat->FormatItem(pItem->GetTemplate(), pItem->GetCount()) << " из банка";
botAI->TellMaster(out.str());
return true;
}
@@ -102,14 +102,14 @@ bool BankAction::Deposit(Item* pItem)
bot->RemoveItem(pItem->GetBagSlot(), pItem->GetSlot(), true);
bot->BankItem(dest, pItem, true);
out << "put " << chat->FormatItem(pItem->GetTemplate(), pItem->GetCount()) << " to bank";
out << "помещено: " << chat->FormatItem(pItem->GetTemplate(), pItem->GetCount()) << " в банк";
botAI->TellMaster(out.str());
return true;
}
void BankAction::ListItems()
{
botAI->TellMaster("=== Bank ===");
botAI->TellMaster("=== Банк ===");
std::map<uint32, uint32> items;
std::map<uint32, bool> soulbound;
@@ -1278,13 +1278,13 @@ bool BGTactics::HandleConsoleCommand(ChatHandler* handler, char const* args)
{
if (!sPlayerbotAIConfig.enabled)
{
handler->PSendSysMessage("|cffff0000Playerbot system is currently disabled!");
handler->PSendSysMessage("|cffff0000Система Playerbot сейчас отключена!");
return true;
}
WorldSession* session = handler->GetSession();
if (!session)
{
handler->PSendSysMessage("Command can only be used from an active session");
handler->PSendSysMessage("Команду можно использовать только из активной игровой сессии");
return true;
}
std::string const commandOutput = HandleConsoleCommandPrivate(session, args);
@@ -4304,7 +4304,7 @@ bool ArenaTactics::Execute(Event /*event*/)
float x, y, z;
target->GetPosition(x, y, z);
botAI->TellMasterNoFacing("Repositioning to exit the LoS target!");
botAI->TellMasterNoFacing("Меняю позицию, чтобы выйти из поля зрения цели!");
return MoveTo(target->GetMapId(), x + frand(-1, +1), y + frand(-1, +1), z, false, true);
}
}
@@ -64,19 +64,19 @@ void BuffAction::TellHeader(uint32 subClass)
switch (subClass)
{
case ITEM_SUBCLASS_ELIXIR:
botAI->TellMaster("--- Elixir ---");
botAI->TellMaster("--- Эликсиры ---");
return;
case ITEM_SUBCLASS_FLASK:
botAI->TellMaster("--- Flask ---");
botAI->TellMaster("--- Настои ---");
return;
case ITEM_SUBCLASS_SCROLL:
botAI->TellMaster("--- Scroll ---");
botAI->TellMaster("--- Свитки ---");
return;
case ITEM_SUBCLASS_FOOD:
botAI->TellMaster("--- Food ---");
botAI->TellMaster("--- Еда ---");
return;
case ITEM_SUBCLASS_ITEM_ENHANCEMENT:
botAI->TellMaster("--- Enchant ---");
botAI->TellMaster("--- Чары ---");
return;
}
}
@@ -197,7 +197,7 @@ bool BuyAction::Execute(Event event)
if (!result)
{
std::ostringstream out;
out << "Nobody sells " << ChatHelper::FormatItem(proto) << " nearby";
out << "Никто не продаёт: " << ChatHelper::FormatItem(proto) << " поблизости";
botAI->TellMaster(out.str());
continue;
}
@@ -215,7 +215,7 @@ bool BuyAction::Execute(Event event)
if (!vendored)
{
botAI->TellError("There are no vendors nearby");
botAI->TellError("Поблизости нет торговцев");
return false;
}
@@ -248,7 +248,7 @@ bool BuyAction::BuyItem(VendorItemData const* tItems, ObjectGuid vendorguid, Ite
if (newCount > oldCount)
{
std::ostringstream out;
out << "Buying " << ChatHelper::FormatItem(proto);
out << "Покупаю: " << ChatHelper::FormatItem(proto);
botAI->TellMaster(out.str());
return true;
}
@@ -117,7 +117,7 @@ bool CastCustomSpellAction::Execute(Event event)
std::ostringstream msg;
if (!spell)
{
msg << "Unknown spell " << text;
msg << "Неизвестное заклинание: " << text;
botAI->TellError(msg.str());
return false;
}
@@ -125,7 +125,7 @@ bool CastCustomSpellAction::Execute(Event event)
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spell);
if (!spellInfo)
{
msg << "Unknown spell " << text;
msg << "Неизвестное заклинание: " << text;
botAI->TellError(msg.str());
return false;
}
@@ -154,7 +154,7 @@ bool CastCustomSpellAction::Execute(Event event)
if (!bot->GetTrader() && !botAI->CanCastSpell(spell, target, true, itemTarget))
{
msg << "Cannot cast " << spellName.str();
msg << "Не удалось применить: " << spellName.str();
botAI->TellError(msg.str());
return false;
}
@@ -162,7 +162,7 @@ bool CastCustomSpellAction::Execute(Event event)
bool result = spell ? botAI->CastSpell(spell, target, itemTarget) : botAI->CastSpell(text, target, itemTarget);
if (result)
{
msg << "Casting " << spellName.str();
msg << "Применяю: " << spellName.str();
if (castCount > 1)
{
@@ -176,7 +176,7 @@ bool CastCustomSpellAction::Execute(Event event)
}
else
{
msg << "Cast " << spellName.str() << " is failed";
msg << "Применение " << spellName.str() << " завершилось ошибкой";
botAI->TellError(msg.str());
}
@@ -15,7 +15,7 @@ bool ChangeChatAction::Execute(Event event)
if (parsed == CHAT_MSG_SYSTEM)
{
std::ostringstream out;
out << "Current chat is " << chat->FormatChat(*context->GetValue<ChatMsg>("chat"));
out << "Текущий канал чата: " << chat->FormatChat(*context->GetValue<ChatMsg>("chat"));
botAI->TellMaster(out);
}
else
@@ -23,7 +23,7 @@ bool ChangeChatAction::Execute(Event event)
context->GetValue<ChatMsg>("chat")->Set(parsed);
std::ostringstream out;
out << "Chat set to " << chat->FormatChat(parsed);
out << "Канал чата установлен: " << chat->FormatChat(parsed);
botAI->TellMaster(out);
}
@@ -45,7 +45,7 @@ bool ChangeNonCombatStrategyAction::Execute(Event event)
{
if (text.find("loot") != std::string::npos || text.find("gather") != std::string::npos)
{
botAI->TellError("You can change any strategy except loot and gather");
botAI->TellError("Можно изменять любую стратегию, кроме loot и gather");
return false;
}
}
@@ -38,7 +38,7 @@ bool ChangeTalentsAction::Execute(Event event)
if (param.find("switch 1") != std::string::npos)
{
bot->ActivateSpec(0);
out << "Active first talent";
out << "Активирован первый набор талантов";
botAI->ResetStrategies();
}
else if (param.find("switch 2") != std::string::npos)
@@ -49,7 +49,7 @@ bool ChangeTalentsAction::Execute(Event event)
bot->CastSpell(bot, 63624, true, nullptr, nullptr, bot->GetGUID());
}
bot->ActivateSpec(1);
out << "Active second talent";
out << "Активирован второй набор талантов";
botAI->ResetStrategies();
}
}
@@ -57,7 +57,7 @@ bool ChangeTalentsAction::Execute(Event event)
{
PlayerbotFactory factory(bot, bot->GetLevel());
factory.InitTalentsTree(true);
out << "Auto pick talents";
out << "Автоматический выбор талантов включён";
botAI->ResetStrategies();
}
else if (param.find("spec list") != std::string::npos)
@@ -78,13 +78,13 @@ bool ChangeTalentsAction::Execute(Event event)
}
else
{
out << "Unknown command.";
out << "Неизвестная команда.";
}
}
else
{
uint32 tab = AiFactory::GetPlayerSpecTab(bot);
out << "My current talent spec is: "
out << "Моя текущая специализация: "
<< "|h|cffffffff";
out << chat->FormatClass(bot, tab) << "\n";
out << TalentsHelp();
@@ -98,7 +98,7 @@ bool ChangeTalentsAction::Execute(Event event)
std::string ChangeTalentsAction::TalentsHelp()
{
std::ostringstream out;
out << "Talents usage: talents switch <1/2>, talents autopick, talents spec list, "
out << "Команды талантов: talents switch <1/2>, talents autopick, talents spec list, "
"talents spec <specName>, talents apply <link>.";
return out.str();
}
@@ -127,7 +127,7 @@ std::string ChangeTalentsAction::SpecList()
out << tabCount[0] << "-" << tabCount[1] << "-" << tabCount[2] << ")";
botAI->TellMasterNoFacing(out.str());
}
out << "Total " << specFound << " specs found";
out << "Найдено: " << specFound << " специализаций";
return out.str();
}
@@ -149,12 +149,12 @@ std::string ChangeTalentsAction::SpecPick(std::string param)
factory.InitGlyphs(false);
std::ostringstream out;
out << "Picking " << sPlayerbotAIConfig.premadeSpecName[cls][specNo];
out << "Выбираю: " << sPlayerbotAIConfig.premadeSpecName[cls][specNo];
return out.str();
}
}
std::ostringstream out;
out << "Spec " << param << " not found";
out << "Специализация " << param << " не найдена";
return out.str();
}
@@ -165,11 +165,11 @@ std::string ChangeTalentsAction::SpecApply(std::string param)
std::vector<std::vector<uint32>> parsedSpecLink = PlayerbotAIConfig::ParseTempTalentsOrder(cls, param);
if (parsedSpecLink.size() == 0)
{
out << "Invalid link " << param;
out << "Неверная ссылка: " << param;
return out.str();
}
PlayerbotFactory::InitTalentsByParsedSpecLink(bot, parsedSpecLink, true);
out << "Applying " << param;
out << "Применяю: " << param;
return out.str();
}
@@ -89,7 +89,7 @@ bool FollowChatShortcutAction::Execute(Event /*event*/)
if (moved)
{
botAI->TellMaster("Following");
botAI->TellMaster("Следую за целью.");
return true;
}
}
@@ -101,10 +101,10 @@ bool FollowChatShortcutAction::Execute(Event /*event*/)
if (bot->isDead())
{
bot->ResurrectPlayer(1.0f, false);
botAI->TellMasterNoFacing("Back from the grave!");
botAI->TellMasterNoFacing("Возвращение из могилы!");
}
else
botAI->TellMaster("You are too far away from me! I will there soon.");
botAI->TellMaster("Вы слишком далеко! Я скоро подойду.");
bot->RemoveAurasWithInterruptFlags(AURA_INTERRUPT_FLAG_TELEPORTED | AURA_INTERRUPT_FLAG_CHANGE_MAP);
bot->TeleportTo(master->GetMapId(), master->GetPositionX(), master->GetPositionY(), master->GetPositionZ(),
@@ -112,7 +112,7 @@ bool FollowChatShortcutAction::Execute(Event /*event*/)
}
*/
botAI->TellMaster("Following");
botAI->TellMaster("Следую за целью.");
return true;
}
@@ -129,7 +129,7 @@ bool StayChatShortcutAction::Execute(Event /*event*/)
SetReturnPosition(bot->GetPositionX(), bot->GetPositionY(), bot->GetPositionZ());
SetStayPosition(bot->GetPositionX(), bot->GetPositionY(), bot->GetPositionZ());
botAI->TellMaster("Staying");
botAI->TellMaster("Остаюсь на месте.");
return true;
}
@@ -144,7 +144,7 @@ bool MoveFromGroupChatShortcutAction::Execute(Event /*event*/)
botAI->ChangeStrategy("+move from group", BOT_STATE_NON_COMBAT);
botAI->ChangeStrategy("+move from group", BOT_STATE_COMBAT);
botAI->TellMaster("Moving away from group");
botAI->TellMaster("Отхожу от группы");
return true;
}
@@ -163,11 +163,11 @@ bool FleeChatShortcutAction::Execute(Event /*event*/)
if (bot->GetMapId() != master->GetMapId() || bot->GetDistance(master) > sPlayerbotAIConfig.sightDistance)
{
botAI->TellError("I will not flee with you - too far away");
botAI->TellError("Я не могу отступать вместе с вами — вы слишком далеко");
return true;
}
botAI->TellMaster("Fleeing");
botAI->TellMaster("Отступаю.");
return true;
}
@@ -184,7 +184,7 @@ bool GoawayChatShortcutAction::Execute(Event /*event*/)
ResetReturnPosition();
ResetStayPosition();
botAI->TellMaster("Running away");
botAI->TellMaster("Отступаю");
return true;
}
@@ -200,7 +200,7 @@ bool GrindChatShortcutAction::Execute(Event /*event*/)
ResetReturnPosition();
ResetStayPosition();
botAI->TellMaster("Grinding");
botAI->TellMaster("Фармлю.");
return true;
}
@@ -220,7 +220,7 @@ bool TankAttackChatShortcutAction::Execute(Event /*event*/)
ResetReturnPosition();
ResetStayPosition();
botAI->TellMaster("Attacking");
botAI->TellMaster("Атакую.");
return true;
}
@@ -236,7 +236,7 @@ bool MaxDpsChatShortcutAction::Execute(Event /*event*/)
botAI->Reset();
botAI->ChangeStrategy("-threat,-conserve mana,-cast time,+dps debuff,+boost", BOT_STATE_COMBAT);
botAI->TellMaster("Max DPS!");
botAI->TellMaster("Максимальный урон!");
return true;
}
@@ -250,6 +250,6 @@ bool BwlChatShortcutAction::Execute(Event /*event*/)
botAI->Reset();
botAI->ChangeStrategy("+bwl", BOT_STATE_NON_COMBAT);
botAI->ChangeStrategy("+bwl", BOT_STATE_COMBAT);
botAI->TellMasterNoFacing("Add Bwl Strategies!");
botAI->TellMasterNoFacing("Стратегии Логова Крыла Тьмы включены!");
return true;
}
@@ -258,7 +258,7 @@ bool ChooseRpgTargetAction::Execute(Event /*event*/)
if (botAI->HasStrategy("debug", BOT_STATE_NON_COMBAT) && guidP.GetWorldObject())
{
std::ostringstream out;
out << "found: ";
out << "найдено: ";
out << chat->FormatWorldobject(guidP.GetWorldObject());
out << " " << relevances.front();
@@ -169,7 +169,7 @@ bool AttackRtiTargetAction::Execute(Event /*event*/)
}
}
else
botAI->TellError("I dont see my rti attack target");
botAI->TellError("Я не вижу назначенную групповую цель для атаки");
return false;
}
@@ -244,15 +244,15 @@ void ChooseTravelTargetAction::ReportTravelTarget(TravelTarget* newTarget, Trave
std::string Sub;
if (newTarget->isGroupCopy())
out << "Following group ";
out << "Следую за группой: ";
else if (oldDestination && oldDestination == destination)
out << "Continuing ";
out << "Продолжаю: ";
else
out << "Traveling ";
out << "Путешествую: ";
out << round(newTarget->getDestination()->distanceTo(&botLocation)) << "y";
out << " for " << chat->FormatQuest(quest);
out << " за " << chat->FormatQuest(quest);
out << " to " << QuestDestination->getTitle();
@@ -265,20 +265,20 @@ void ChooseTravelTargetAction::ReportTravelTarget(TravelTarget* newTarget, Trave
WorldPosition botLocation(bot);
if (newTarget->isGroupCopy())
out << "Following group ";
out << "Следую за группой: ";
else if (oldDestination && oldDestination == destination)
out << "Continuing ";
out << "Продолжаю: ";
else
out << "Traveling ";
out << "Путешествую: ";
out << round(newTarget->getDestination()->distanceTo(&botLocation)) << "y";
out << " for ";
out << " за ";
if (AI_VALUE2(bool, "group or", "should sell,can sell"))
out << "selling items";
out << "продаю предметы";
else if (AI_VALUE2(bool, "group or", "should repair,can repair"))
out << "repairing";
out << "ремонтируюсь";
else
out << "rpg";
@@ -293,11 +293,11 @@ void ChooseTravelTargetAction::ReportTravelTarget(TravelTarget* newTarget, Trave
WorldPosition botLocation(bot);
if (newTarget->isGroupCopy())
out << "Following group ";
out << "Следую за группой: ";
else if (oldDestination && oldDestination == destination)
out << "Continuing ";
out << "Продолжаю: ";
else
out << "Traveling ";
out << "Путешествую: ";
out << round(newTarget->getDestination()->distanceTo(&botLocation)) << "y";
@@ -314,11 +314,11 @@ void ChooseTravelTargetAction::ReportTravelTarget(TravelTarget* newTarget, Trave
WorldPosition botLocation(bot);
if (newTarget->isGroupCopy())
out << "Following group ";
out << "Следую за группой: ";
else if (oldDestination && oldDestination == destination)
out << "Continuing ";
out << "Продолжаю: ";
else
out << "Traveling ";
out << "Путешествую: ";
out << round(newTarget->getDestination()->distanceTo(&botLocation)) << "y";
@@ -335,11 +335,11 @@ void ChooseTravelTargetAction::ReportTravelTarget(TravelTarget* newTarget, Trave
WorldPosition botLocation(bot);
if (newTarget->isGroupCopy())
out << "Following group ";
out << "Следую за группой: ";
else if (oldDestination && oldDestination == destination)
out << "Continuing ";
out << "Продолжаю: ";
else
out << "Traveling ";
out << "Путешествую: ";
out << round(newTarget->getDestination()->distanceTo(&botLocation)) << "y";
@@ -353,7 +353,7 @@ void ChooseTravelTargetAction::ReportTravelTarget(TravelTarget* newTarget, Trave
{
if (!oldTarget->getDestination() || oldTarget->getDestination()->getName() != "NullTravelDestination")
{
botAI->TellMaster("No where to travel. Idling a bit.");
botAI->TellMaster("Некуда идти. Немного подожду.");
}
}
}
@@ -31,7 +31,7 @@ bool CustomStrategyEditAction::Execute(Event event)
bool CustomStrategyEditAction::PrintHelp()
{
botAI->TellMaster("=== Custom strategies ===");
botAI->TellMaster("=== Пользовательские стратегии ===");
uint32 owner = botAI->GetBot()->GetGUID().GetCounter();
@@ -48,7 +48,7 @@ bool CustomStrategyEditAction::PrintHelp()
} while (result->NextRow());
}
botAI->TellMaster("Usage: cs <name> <idx> <command>");
botAI->TellMaster("Использование: cs <имя> <индекс> <команда>");
return false;
}
@@ -79,7 +79,7 @@ bool DebugAction::Execute(Event event)
TravelNodeRoute route = TravelNodeMap::instance().getRoute(botPos, *points.front(), beginPath, bot);
std::ostringstream out;
out << "Traveling to " << dest->getTitle() << ": ";
out << "Направляюсь к: " << dest->getTitle() << ": ";
for (auto node : route.getNodes())
{
@@ -92,7 +92,7 @@ bool DebugAction::Execute(Event event)
}
else
{
botAI->TellMasterNoFacing("Destination " + destination + " not found.");
botAI->TellMasterNoFacing("Точка назначения " + destination + " not found.");
return true;
}
}
@@ -104,7 +104,7 @@ bool DebugAction::Execute(Event event)
if (!quest)
{
botAI->TellMasterNoFacing("Quest " + text.substr(6) + " not found.");
botAI->TellMasterNoFacing("Задание " + text.substr(6) + " not found.");
return false;
}
@@ -164,7 +164,7 @@ bool DebugAction::Execute(Event event)
else if (text.find("bquest") != std::string::npos)
{
std::ostringstream out;
out << "bad quests:";
out << "ошибочные задания:";
// uint32 noT = 0, noG = 0, noO = 0; //not used, line marked for removal.
@@ -205,7 +205,7 @@ bool DebugAction::Execute(Event event)
endNode->setLinked(false);
}
botAI->TellMasterNoFacing("Node " + name + " created.");
botAI->TellMasterNoFacing("Узел " + name + " created.");
TravelNodeMap::instance().setHasToGen();
@@ -222,12 +222,12 @@ bool DebugAction::Execute(Event event)
if (startNode->isImportant())
{
botAI->TellMasterNoFacing("Node can not be removed.");
botAI->TellMasterNoFacing("Не удалось удалить узел.");
}
TravelNodeMap::instance().m_nMapMtx.lock();
TravelNodeMap::instance().removeNode(startNode);
botAI->TellMasterNoFacing("Node removed.");
botAI->TellMasterNoFacing("Узел удалён.");
TravelNodeMap::instance().m_nMapMtx.unlock();
TravelNodeMap::instance().setHasToGen();
@@ -51,10 +51,10 @@ bool DropQuestAction::Execute(Event event)
const Quest* pQuest = sObjectMgr->GetQuestTemplate(entry);
const std::string text_quest = ChatHelper::FormatQuest(pQuest);
LOG_INFO("playerbots", "{} => Quest [ {} ] removed", bot->GetName(), pQuest->GetTitle());
bot->Say("Quest [ " + text_quest + " ] removed", LANG_UNIVERSAL);
bot->Say("Задание [ " + text_quest + " ] removed", LANG_UNIVERSAL);
}
botAI->TellMaster("Quest removed");
botAI->TellMaster("Задание удалено.");
return true;
}
@@ -63,7 +63,7 @@ bool CleanQuestLogAction::Execute(Event event)
Player* requester = event.getOwner() ? event.getOwner() : GetMaster();
if (!requester)
{
botAI->TellMaster("No event owner detected");
botAI->TellMaster("Не удалось определить владельца события");
return false;
}
@@ -75,7 +75,7 @@ bool CleanQuestLogAction::Execute(Event event)
// Only output this message if "debug rpg" strategy is enabled
if (botAI->HasStrategy("debug rpg", BotState::BOT_STATE_COMBAT))
{
botAI->TellMaster("Clean Quest Log command received, removing grey/trivial quests...");
botAI->TellMaster("Получена команда очистки журнала: удаляю серые и незначительные задания...");
}
uint8 botLevel = bot->GetLevel(); // Get bot's level
@@ -127,7 +127,7 @@ bool CleanQuestLogAction::Execute(Event event)
// Output only if "debug rpg" strategy is enabled
if (botAI->HasStrategy("debug rpg", BotState::BOT_STATE_COMBAT))
{
botAI->TellMaster("Quest [ " + quest->GetTitle() + " ] will be removed because it is trivial (grey).");
botAI->TellMaster("Задание [ " + quest->GetTitle() + " ] will be removed because it is trivial (grey).");
}
// Remove quest
@@ -143,12 +143,12 @@ bool CleanQuestLogAction::Execute(Event event)
{
const std::string text_quest = ChatHelper::FormatQuest(quest);
LOG_INFO("playerbots", "{} => Quest [ {} ] removed", bot->GetName(), quest->GetTitle());
bot->Say("Quest [ " + text_quest + " ] removed", LANG_UNIVERSAL);
bot->Say("Задание [ " + text_quest + " ] removed", LANG_UNIVERSAL);
}
if (botAI->HasStrategy("debug rpg", BotState::BOT_STATE_COMBAT))
{
botAI->TellMaster("Quest [ " + quest->GetTitle() + " ] has been removed.");
botAI->TellMaster("Задание [ " + quest->GetTitle() + " ] has been removed.");
}
}
else
@@ -156,7 +156,7 @@ bool CleanQuestLogAction::Execute(Event event)
// Only output if "debug rpg" strategy is enabled
if (botAI->HasStrategy("debug rpg", BotState::BOT_STATE_COMBAT))
{
botAI->TellMaster("Quest [ " + quest->GetTitle() + " ] is not trivial and will be kept.");
botAI->TellMaster("Задание [ " + quest->GetTitle() + " ] is not trivial and will be kept.");
}
}
}
@@ -234,9 +234,9 @@ void CleanQuestLogAction::DropQuestType(uint8& numQuest, uint8 wantNum, bool isG
{
const std::string text_quest = ChatHelper::FormatQuest(quest);
LOG_INFO("playerbots", "{} => Quest [ {} ] removed", bot->GetName(), quest->GetTitle());
bot->Say("Quest [ " + text_quest + " ] removed", LANG_UNIVERSAL);
bot->Say("Задание [ " + text_quest + " ] removed", LANG_UNIVERSAL);
}
botAI->TellMaster("Quest removed" + chat->FormatQuest(quest));
botAI->TellMaster("Задание удалено." + chat->FormatQuest(quest));
}
}
@@ -163,7 +163,7 @@ bool EmoteActionBase::ReceiveEmote(Player* source, uint32 emote, bool verbal)
if (botAI->GetMaster() == source)
{
botAI->ChangeStrategy("-follow,+stay", BOT_STATE_NON_COMBAT);
botAI->TellMasterNoFacing("Fine.. I'll stay right here..");
botAI->TellMasterNoFacing("Хорошо... Останусь здесь...");
}
break;
case TEXT_EMOTE_BECKON:
@@ -171,7 +171,7 @@ bool EmoteActionBase::ReceiveEmote(Player* source, uint32 emote, bool verbal)
if (botAI->GetMaster() == source)
{
botAI->ChangeStrategy("+follow", BOT_STATE_NON_COMBAT);
botAI->TellMasterNoFacing("Wherever you go, I'll follow..");
botAI->TellMasterNoFacing("Куда бы вы ни пошли, я последую за вами...");
}
break;
case TEXT_EMOTE_WAVE:
@@ -579,36 +579,36 @@ bool EmoteActionBase::ReceiveEmote(Player* source, uint32 emote, bool verbal)
break;
/*case TEXT_EMOTE_BADFEELING:
bot->HandleEmoteCommand(EMOTE_ONESHOT_QUESTION);
bot->Say("I'm just waiting for the ominous music now...", LANG_UNIVERSAL);
bot->Say("Теперь осталось дождаться зловещей музыки...", LANG_UNIVERSAL);
break;
case TEXT_EMOTE_MAP:
bot->HandleEmoteCommand(EMOTE_ONESHOT_NO);
bot->Say("Noooooooo.. you just couldn't ask for directions, huh?", LANG_UNIVERSAL);
bot->Say("Неееет... Нельзя было просто спросить дорогу?", LANG_UNIVERSAL);
break;
case TEXT_EMOTE_IDEA:
case TEXT_EMOTE_THINK:
bot->HandleEmoteCommand(EMOTE_ONESHOT_NO);
bot->Say("Oh boy.. another genius idea...", LANG_UNIVERSAL);
bot->Say("Ох... Ещё одна гениальная идея...", LANG_UNIVERSAL);
break;
case TEXT_EMOTE_OFFER:
bot->HandleEmoteCommand(EMOTE_ONESHOT_NO);
bot->Say("No thanks.. I had some back at the last village", LANG_UNIVERSAL);
bot->Say("Нет, спасибо... В прошлой деревне уже хватило", LANG_UNIVERSAL);
break;
case TEXT_EMOTE_PET:
bot->HandleEmoteCommand(EMOTE_ONESHOT_ROAR);
bot->Say("Do I look like a dog to you?!", LANG_UNIVERSAL);
bot->Say("Я похож на собаку?!", LANG_UNIVERSAL);
break;
case TEXT_EMOTE_ROLLEYES:
bot->HandleEmoteCommand(EMOTE_ONESHOT_POINT);
bot->Say("Keep doing that and I'll roll those eyes right out of your head..", LANG_UNIVERSAL);
bot->Say("Продолжайте — и глаза скоро закатятся навсегда...", LANG_UNIVERSAL);
break;
case TEXT_EMOTE_SING:
bot->HandleEmoteCommand(EMOTE_ONESHOT_APPLAUD);
bot->Say("Lovely... just lovely..", LANG_UNIVERSAL);
bot->Say("Прекрасно... Просто прекрасно...", LANG_UNIVERSAL);
break;
case TEXT_EMOTE_COVEREARS:
bot->HandleEmoteCommand(EMOTE_ONESHOT_EXCLAMATION);
bot->Yell("You think that's going to help you?!", LANG_UNIVERSAL);
bot->Yell("Думаете, это вам поможет?!", LANG_UNIVERSAL);
break;*/
default:
// return false;
@@ -75,7 +75,7 @@ void EquipAction::EquipItem(Item* item)
{
bot->SetAmmo(itemId);
std::ostringstream out;
out << "equipping " << chat->FormatItem(itemProto);
out << "Надеваю: " << chat->FormatItem(itemProto);
botAI->TellMaster(out);
return;
}
@@ -112,7 +112,7 @@ void EquipAction::EquipItem(Item* item)
bot->GetSession()->HandleAutoEquipItemSlotOpcode(nicePacket);
std::ostringstream out;
out << "Equipping " << chat->FormatItem(itemProto) << " in ranged slot";
out << "Надеваю: " << chat->FormatItem(itemProto) << " в ячейку оружия дальнего боя";
botAI->TellMaster(out);
return;
}
@@ -227,7 +227,7 @@ void EquipAction::EquipItem(Item* item)
}
std::ostringstream out;
out << "Equipping " << chat->FormatItem(itemProto) << " in main hand";
out << "Надеваю: " << chat->FormatItem(itemProto) << " в правую руку";
botAI->TellMaster(out);
return;
}
@@ -244,7 +244,7 @@ void EquipAction::EquipItem(Item* item)
bot->GetSession()->HandleAutoEquipItemSlotOpcode(nicePacket);
std::ostringstream out;
out << "Equipping " << chat->FormatItem(itemProto) << " in offhand";
out << "Надеваю: " << chat->FormatItem(itemProto) << " в левую руку";
botAI->TellMaster(out);
return;
}
@@ -324,7 +324,7 @@ void EquipAction::EquipItem(Item* item)
}
std::ostringstream out;
out << "Equipping " << chat->FormatItem(itemProto);
out << "Надеваю: " << chat->FormatItem(itemProto);
botAI->TellMaster(out);
}
@@ -106,7 +106,7 @@ bool EquipGlyphsAction::Execute(Event event)
std::vector<GlyphInfo const*> glyphs;
if (!CollectGlyphs(itemIds, glyphs))
{
botAI->TellMaster("Usage: glyph equip <6 glyph item IDs> (3 major, 3 minor).");
botAI->TellMaster("Использование: glyph equip <6 ID символов> (3 больших и 3 малых).");
return false;
}
@@ -144,12 +144,12 @@ bool EquipGlyphsAction::Execute(Event event)
if (!placed)
{
botAI->TellMaster("Not enought empty sockets for all glyphs.");
botAI->TellMaster("Недостаточно свободных ячеек для всех символов.");
return false;
}
}
botAI->TellMaster("Glyphs updated.");
botAI->TellMaster("Символы обновлены.");
// Flag for custom glyphs
botAI->GetAiObjectContext()->GetValue<bool>("custom_glyphs")->Set(true);
@@ -10,7 +10,7 @@
bool FlagAction::TellUsage()
{
botAI->TellError("Usage: flag cloak/helm/pvp on/set/off/clear/toggle/?");
botAI->TellError("Использование: flag cloak/helm/pvp on/set/off/clear/toggle/?");
return false;
}
@@ -130,15 +130,15 @@ bool FleeToGroupLeaderAction::Execute(Event /*event*/)
if (distance < sPlayerbotAIConfig.reactDistance * 3)
{
if (!urand(0, 3))
botAI->TellMaster("I am close, wait for me!");
botAI->TellMaster("Я рядом, подождите меня!");
}
else if (distance < 1000)
{
if (!urand(0, 10))
botAI->TellMaster("I heading to your position.");
botAI->TellMaster("Я направляюсь к вам.");
}
else if (!urand(0, 20))
botAI->TellMaster("I am traveling to your position.");
botAI->TellMaster("Я иду к вам.");
botAI->SetNextCheckDelay(3000);
@@ -112,7 +112,7 @@ bool TogglePetSpellAutoCastAction::Execute(Event /*event*/)
// Debug message if pet spells have been toggled and debug is enabled
if (toggled && sPlayerbotAIConfig.petChatCommandDebug == 1)
botAI->TellMaster("Pet autocast spells have been toggled.");
botAI->TellMaster("Автоматическое применение способностей питомца переключено.");
return toggled;
}
@@ -178,7 +178,7 @@ bool SetPetStanceAction::Execute(Event /*event*/)
// If there are no controlled pets or guardians, notify the player and exit
if (targets.empty())
{
botAI->TellError("You have no pet or guardian pet.");
botAI->TellError("У вас нет питомца или стража.");
return false;
}
@@ -220,7 +220,7 @@ bool SetPetStanceAction::Execute(Event /*event*/)
// If debug is enabled in config, inform the master of the new stance
if (sPlayerbotAIConfig.petChatCommandDebug == 1)
botAI->TellMaster("Pet stance set to " + stanceText + " (applied to all pets/guardians).");
botAI->TellMaster("Режим питомца: " + stanceText + " (applied to all pets/guardians).");
return true;
}
@@ -45,13 +45,13 @@ bool GiveItemAction::Execute(Event /*event*/)
moved = true;
std::ostringstream out;
out << "Got " << chat->FormatItem(item->GetTemplate(), item->GetCount()) << " from " << bot->GetName();
out << "Получено: " << chat->FormatItem(item->GetTemplate(), item->GetCount()) << " от " << bot->GetName();
receiverAi->TellMasterNoFacing(out.str());
}
else
{
std::ostringstream out;
out << "Cannot get " << chat->FormatItem(item->GetTemplate(), item->GetCount()) << " from "
out << "Не удалось получить: " << chat->FormatItem(item->GetTemplate(), item->GetCount()) << " от "
<< bot->GetName() << "- my bags are full";
receiverAi->TellError(out.str());
}
@@ -30,7 +30,7 @@ bool GoAction::Execute(Event event)
Map2ZoneCoordinates(x, y, bot->GetZoneId());
std::ostringstream out;
out << "I am at " << x << "," << y;
out << "Я нахожусь в точке " << x << "," << y;
botAI->TellMaster(out.str());
return true;
}
@@ -53,14 +53,14 @@ bool GoAction::Execute(Event event)
target->setForced(true);
std::ostringstream out;
out << "Traveling to " << dest->getTitle();
out << "Направляюсь к: " << dest->getTitle();
botAI->TellMasterNoFacing(out.str());
return true;
}
else
{
botAI->TellMasterNoFacing("Clearing travel target");
botAI->TellMasterNoFacing("Цель путешествия сброшена");
target->setTarget(TravelMgr::instance().nullTravelDestination, TravelMgr::instance().nullWorldPosition);
target->setForced(false);
return true;
@@ -78,12 +78,12 @@ bool GoAction::Execute(Event event)
if (ServerFacade::instance().IsDistanceGreaterThan(ServerFacade::instance().GetDistance2d(bot, go),
sPlayerbotAIConfig.reactDistance))
{
botAI->TellError("It is too far away");
botAI->TellError("Слишком далеко.");
return false;
}
std::ostringstream out;
out << "Moving to " << ChatHelper::FormatGameobject(go);
out << "Двигаюсь к: " << ChatHelper::FormatGameobject(go);
botAI->TellMasterNoFacing(out.str());
return MoveNear(bot->GetMapId(), go->GetPositionX(), go->GetPositionY(), go->GetPositionZ() + 0.5f,
sPlayerbotAIConfig.followDistance);
@@ -103,7 +103,7 @@ bool GoAction::Execute(Event event)
if (strstri(unit->GetName().c_str(), param.c_str()))
{
std::ostringstream out;
out << "Moving to " << unit->GetName();
out << "Двигаюсь к: " << unit->GetName();
botAI->TellMasterNoFacing(out.str());
return MoveNear(bot->GetMapId(), unit->GetPositionX(), unit->GetPositionY(),
unit->GetPositionZ() + 0.5f, sPlayerbotAIConfig.followDistance);
@@ -137,7 +137,7 @@ bool GoAction::Execute(Event event)
out << x << ";" << y << ";" << z << " =";
out << "path is: ";
out << "путь: ";
out << type;
@@ -179,20 +179,20 @@ bool GoAction::Execute(Event event)
if (ServerFacade::instance().IsDistanceGreaterThan(ServerFacade::instance().GetDistance2d(bot, x, y),
sPlayerbotAIConfig.reactDistance))
{
botAI->TellMaster("It is too far away");
botAI->TellMaster("Слишком далеко.");
return false;
}
if (map->IsInWater(bot->GetPhaseMask(), x, y, z, bot->GetCollisionHeight()))
{
botAI->TellError("It is in water");
botAI->TellError("Цель находится в воде");
return false;
}
float ground = map->GetHeight(x, y, z + 0.5f);
if (ground <= INVALID_HEIGHT)
{
botAI->TellError("I can't go there");
botAI->TellError("Я не могу туда пройти");
return false;
}
@@ -200,7 +200,7 @@ bool GoAction::Execute(Event event)
Map2ZoneCoordinates(x1, y1, bot->GetZoneId());
std::ostringstream out;
out << "Moving to " << x1 << "," << y1;
out << "Двигаюсь к: " << x1 << "," << y1;
botAI->TellMasterNoFacing(out.str());
return MoveNear(bot->GetMapId(), x, y, z + 0.5f, sPlayerbotAIConfig.followDistance);
@@ -212,16 +212,16 @@ bool GoAction::Execute(Event event)
if (ServerFacade::instance().IsDistanceGreaterThan(ServerFacade::instance().GetDistance2d(bot, pos.x, pos.y),
sPlayerbotAIConfig.reactDistance))
{
botAI->TellError("It is too far away");
botAI->TellError("Слишком далеко.");
return false;
}
std::ostringstream out;
out << "Moving to position " << param;
out << "Двигаюсь к позиции: " << param;
botAI->TellMasterNoFacing(out.str());
return MoveNear(bot->GetMapId(), pos.x, pos.y, pos.z + 0.5f, sPlayerbotAIConfig.followDistance);
}
botAI->TellMaster("Whisper 'go x,y', 'go [game object]', 'go unit' or 'go position' and I will go there");
botAI->TellMaster("Напишите «go x,y», «go [объект]», «go unit» или «go position», и я отправлюсь туда");
return false;
}
@@ -83,7 +83,7 @@ bool GossipHelloAction::ProcessGossip(int32 menuToSelect, bool silent)
if (menuToSelect != -1 && !menu.GetItem(menuToSelect))
{
if (!silent)
botAI->TellError("Unknown gossip option");
botAI->TellError("Неизвестный вариант диалога");
return false;
}
@@ -138,7 +138,7 @@ bool GossipHelloAction::Execute(ObjectGuid guid, int32 menuToSelect, bool silent
else if (!bot->PlayerTalkClass)
{
if (!silent)
botAI->TellError("I need to talk first");
botAI->TellError("Сначала нужно поговорить");
return false;
}
else
@@ -28,17 +28,17 @@ bool GuildAcceptAction::Execute(Event event)
uint32 guildId = inviter->GetGuildId();
if (!guildId)
{
botAI->TellError("You are not in a guild!");
botAI->TellError("Вы не состоите в гильдии!");
accept = false;
}
else if (bot->GetGuildId())
{
botAI->TellError("Sorry, I am in a guild already");
botAI->TellError("Я уже состою в гильдии");
accept = false;
}
else if (!botAI->GetSecurity()->CheckLevelFor(PLAYERBOT_SECURITY_INVITE, false, inviter, true))
{
botAI->TellError("Sorry, I don't want to join your guild :(");
botAI->TellError("Я не хочу вступать в вашу гильдию :(");
accept = false;
}
@@ -17,7 +17,7 @@ bool GuildBankAction::Execute(Event event)
if (!bot->GetGuildId() || (GetMaster() && GetMaster()->GetGuildId() != bot->GetGuildId()))
{
botAI->TellMaster("I'm not in your guild!");
botAI->TellMaster("Я не состою в вашей гильдии!");
return false;
}
@@ -31,7 +31,7 @@ bool GuildBankAction::Execute(Event event)
return Execute(text, go);
}
botAI->TellMaster("Cannot find the guild bank nearby");
botAI->TellMaster("Поблизости нет банка гильдии");
return false;
}
@@ -65,11 +65,11 @@ bool GuildBankAction::MoveFromCharToBank(Item* item, GameObject* bank)
// check source pos rights (item moved to bank)
if (!guild->MemberHasTabRights(bot->GetGUID(), 0, GUILD_BANK_RIGHT_DEPOSIT_ITEM))
out << "I can't put " << chat->FormatItem(item->GetTemplate())
out << "Я не могу положить: " << chat->FormatItem(item->GetTemplate())
<< " to guild bank. I have no rights to put items in the first guild bank tab";
else
{
out << chat->FormatItem(item->GetTemplate()) << " put to guild bank";
out << chat->FormatItem(item->GetTemplate()) << " положено в банк гильдии";
guild->SwapItemsWithInventory(bot, false, 0, 255, playerBag, playerSlot, 0);
}
@@ -298,7 +298,7 @@ bool GuildLeaveAction::Execute(Event event)
Player* owner = event.getOwner();
if (owner && !botAI->GetSecurity()->CheckLevelFor(PLAYERBOT_SECURITY_INVITE, false, owner, true))
{
botAI->TellError("Sorry, I am happy in my guild :)");
botAI->TellError("Меня устраивает моя гильдия :)");
return false;
}
@@ -23,7 +23,7 @@ bool HelpAction::Execute(Event /*event*/)
void HelpAction::TellChatCommands()
{
std::ostringstream out;
out << "Whisper any of: ";
out << "Напишите одну из команд: ";
out << CombineSupported(chatContext->supports());
out << ", [item], [quest] or [object] link";
botAI->TellError(out.str());
@@ -32,7 +32,7 @@ void HelpAction::TellChatCommands()
void HelpAction::TellStrategies()
{
std::ostringstream out;
out << "Possible strategies (co/nc/dead commands): ";
out << "Доступные стратегии (команды co/nc/dead): ";
out << CombineSupported(botAI->GetAiObjectContext()->GetSupportedStrategies());
botAI->TellError(out.str());
}
@@ -30,13 +30,13 @@ bool HireAction::Execute(Event /*event*/)
if (charCount >= 10)
{
botAI->TellMaster("You already have the maximum number of characters");
botAI->TellMaster("У вас уже максимальное количество персонажей");
return false;
}
if (bot->GetLevel() > master->GetLevel())
{
botAI->TellMaster("You cannot hire higher level characters than you");
botAI->TellMaster("Нельзя нанять персонажа выше вашего уровня");
return false;
}
@@ -46,13 +46,13 @@ bool HireAction::Execute(Event /*event*/)
if (discount < moneyReq)
{
std::ostringstream out;
out << "You cannot hire me - I barely know you. Make sure you have at least " << chat->formatMoney(moneyReq)
out << "Вы не можете меня нанять: мы почти не знакомы. Требуется не менее " << chat->formatMoney(moneyReq)
<< " as a trade discount";
botAI->TellMaster(out.str());
return false;
}
botAI->TellMaster("I will join you at your next relogin");
botAI->TellMaster("Я присоединюсь после вашего следующего входа в игру");
bot->SetMoney(moneyReq);
RandomPlayerbotMgr::instance().Remove(bot);
@@ -130,40 +130,40 @@ void InventoryAction::TellItems(std::map<uint32, uint32> itemMap, std::map<uint3
switch (proto->Class)
{
case ITEM_CLASS_CONSUMABLE:
botAI->TellMaster("--- consumable ---");
botAI->TellMaster("--- Расходуемые предметы ---");
break;
case ITEM_CLASS_CONTAINER:
botAI->TellMaster("--- container ---");
botAI->TellMaster("--- Сумки ---");
break;
case ITEM_CLASS_WEAPON:
botAI->TellMaster("--- weapon ---");
botAI->TellMaster("--- Оружие ---");
break;
case ITEM_CLASS_ARMOR:
botAI->TellMaster("--- armor ---");
botAI->TellMaster("--- Доспехи ---");
break;
case ITEM_CLASS_REAGENT:
botAI->TellMaster("--- reagent ---");
botAI->TellMaster("--- Реагенты ---");
break;
case ITEM_CLASS_PROJECTILE:
botAI->TellMaster("--- projectile ---");
botAI->TellMaster("--- Боеприпасы ---");
break;
case ITEM_CLASS_TRADE_GOODS:
botAI->TellMaster("--- trade goods ---");
botAI->TellMaster("--- Хозяйственные товары ---");
break;
case ITEM_CLASS_RECIPE:
botAI->TellMaster("--- recipe ---");
botAI->TellMaster("--- Рецепты ---");
break;
case ITEM_CLASS_QUIVER:
botAI->TellMaster("--- quiver ---");
botAI->TellMaster("--- Колчаны ---");
break;
case ITEM_CLASS_QUEST:
botAI->TellMaster("--- quest items ---");
botAI->TellMaster("--- Предметы заданий ---");
break;
case ITEM_CLASS_KEY:
botAI->TellMaster("--- keys ---");
botAI->TellMaster("--- Ключи ---");
break;
case ITEM_CLASS_MISC:
botAI->TellMaster("--- other ---");
botAI->TellMaster("--- Прочее ---");
break;
}
}
@@ -438,7 +438,7 @@ bool LfgAction::Execute(Event event)
std::ostringstream out;
if (allowedRoles[role] > 1)
{
out << "Joining as " << placeholders["%role"] << ", " << placeholders["%spotsleft"] << " "
out << "Присоединяюсь в роли: " << placeholders["%role"] << ", " << placeholders["%spotsleft"] << " "
<< placeholders["%role"] << " spots left.";
botAI->TellMasterNoFacing(out.str());
@@ -447,7 +447,7 @@ bool LfgAction::Execute(Event event)
}
else
{
out << "Joining as " << placeholders["%role"] << ".";
out << "Присоединяюсь в роли: " << placeholders["%role"] << ".";
botAI->TellMasterNoFacing(out.str());
//botAI->DoSpecificAction("autogear");
@@ -86,7 +86,7 @@ bool LeaveGroupAction::Leave()
Player* master = botAI -> GetMaster();
if (master)
botAI->TellMaster("Goodbye!", PLAYERBOT_SECURITY_TALK);
botAI->TellMaster("До свидания!", PLAYERBOT_SECURITY_TALK);
botAI->LeaveOrDisbandGroup();
return true;
@@ -44,19 +44,19 @@ void ListQuestsAction::ListQuests(QuestListFilter filter, QuestTravelDetail trav
bool showCompleted = filter & QUEST_LIST_FILTER_COMPLETED;
if (showIncompleted)
botAI->TellMaster("--- Incompleted quests ---");
botAI->TellMaster("--- Невыполненные задания ---");
uint32 incompleteCount = ListQuests(false, !showIncompleted, travelDetail);
if (showCompleted)
botAI->TellMaster("--- Completed quests ---");
botAI->TellMaster("--- Выполненные задания ---");
uint32 completeCount = ListQuests(true, !showCompleted, travelDetail);
botAI->TellMaster("--- Summary ---");
botAI->TellMaster("--- Итого ---");
std::ostringstream out;
out << "Total: " << (completeCount + incompleteCount) << " / 25 (incompleted: " << incompleteCount
out << "Всего: " << (completeCount + incompleteCount) << " / 25 (невыполнено: " << incompleteCount
<< ", completed: " << completeCount << ")";
botAI->TellMaster(out);
}
@@ -284,11 +284,11 @@ bool ListSpellsAction::Execute(Event event)
if (spells.empty())
{
// CHANGE: Give early feedback when no spells match the filter.
botAI->TellMaster("No spells found.");
botAI->TellMaster("Заклинания не найдены.");
return true;
}
botAI->TellMaster("=== Spells ===");
botAI->TellMaster("=== Заклинания ===");
std::sort(spells.begin(), spells.end(), CompareSpells);
@@ -17,11 +17,11 @@ bool LogLevelAction::Execute(Event event)
if (param != "?")
{
value->Set(string2logLevel(param));
out << "My log level set to " << logLevel2string(value->Get());
out << "Уровень журнала установлен: " << logLevel2string(value->Get());
}
else
{
out << "My log level is " << logLevel2string(value->Get());
out << "Текущий уровень журнала: " << logLevel2string(value->Get());
}
botAI->TellMaster(out);
@@ -24,14 +24,14 @@ bool LootStrategyAction::Execute(Event event)
{
{
std::ostringstream out;
out << "Loot strategy: ";
out << "Стратегия добычи: ";
out << lootStrategy->Get()->GetName();
botAI->TellMaster(out);
}
{
std::ostringstream out;
out << "Always loot items: ";
out << "Всегда собирать: ";
for (uint32 itemId : alwaysLootItems)
{
@@ -54,7 +54,7 @@ bool LootStrategyAction::Execute(Event event)
lootStrategy->Set(LootStrategyValue::instance(strategy));
std::ostringstream out;
out << "Loot strategy set to " << lootStrategy->Get()->GetName();
out << "Установлена стратегия добычи: " << lootStrategy->Get()->GetName();
botAI->TellMaster(out);
return true;
}
@@ -80,12 +80,12 @@ bool LootStrategyAction::Execute(Event event)
if (j != alwaysLootItems.end())
alwaysLootItems.erase(j);
botAI->TellMaster("Item(s) removed from always loot list");
botAI->TellMaster("Предметы удалены из списка обязательного сбора");
}
else
{
alwaysLootItems.insert(itemid);
botAI->TellMaster("Item(s) added to always loot list");
botAI->TellMaster("Предметы добавлены в список обязательного сбора");
}
}
}
@@ -17,7 +17,7 @@ class TellMailProcessor : public MailProcessor
public:
bool Before(PlayerbotAI* botAI) override
{
botAI->TellMaster("=== Mailbox ===");
botAI->TellMaster("=== Почта ===");
tells.clear();
return true;
}
@@ -83,7 +83,7 @@ public:
Player* bot = botAI->GetBot();
if (!CheckBagSpace(bot))
{
botAI->TellError("Not enough bag space");
botAI->TellError("В сумках недостаточно места");
return false;
}
@@ -256,7 +256,7 @@ bool MailAction::Execute(Event event)
if (!MailProcessor::FindMailbox(botAI))
{
botAI->TellError("There is no mailbox nearby");
botAI->TellError("Поблизости нет почтового ящика");
return false;
}
@@ -33,7 +33,7 @@ bool MoveToRpgTargetAction::Execute(Event /*event*/)
if (botAI->HasStrategy("debug rpg", BOT_STATE_NON_COMBAT) && guidP.GetWorldObject())
{
std::ostringstream out;
out << "Heading to: ";
out << "Направляюсь к цели: ";
out << chat->FormatWorldobject(guidP.GetWorldObject());
botAI->TellMasterNoFacing(out);
}
@@ -55,9 +55,9 @@ bool MoveToTravelTargetAction::Execute(Event /*event*/)
{
std::ostringstream out;
if (botAI->GetMaster() && !bot->GetGroup()->IsMember(botAI->GetMaster()->GetGUID()))
out << "Waiting a bit for ";
out << "Немного подожду: ";
else
out << "Please hurry up ";
out << "Пожалуйста, поторопитесь: ";
out << member->GetName();
@@ -164,7 +164,7 @@ bool MovementAction::MoveToLOS(WorldObject* target, bool ranged)
if (dest.isSet())
return MoveTo(dest.mapId, dest.x, dest.y, dest.z);
else
botAI->TellError("All paths not in LOS");
botAI->TellError("Ни один путь не находится в поле зрения");
return false;
}
@@ -1160,13 +1160,13 @@ bool MovementAction::Follow(Unit* target, float distance, float angle)
if (bot->isDead() && botAI->GetMaster()->IsAlive())
{
bot->ResurrectPlayer(1.0f, false);
botAI->TellMasterNoFacing("I live, again!");
botAI->TellMasterNoFacing("Я снова жив!");
}
else
botAI->TellError("I am stuck while following");
botAI->TellError("Я застрял во время следования");
bot->CombatStop(true);
botAI->TellMasterNoFacing("I will there soon.");
botAI->TellMasterNoFacing("Я скоро буду там.");
bot->RemoveAurasWithInterruptFlags(AURA_INTERRUPT_FLAG_TELEPORTED | AURA_INTERRUPT_FLAG_CHANGE_MAP);
bot->TeleportTo(target->GetMapId(), target->GetPositionX(), target->GetPositionY(), target->GetPositionZ(),
target->GetOrientation()); return false;
@@ -1379,7 +1379,7 @@ bool MovementAction::Flee(Unit* target)
if (!IsMovingAllowed())
{
botAI->TellError("I am stuck while fleeing");
botAI->TellError("Я застрял при отступлении");
return false;
}
@@ -1535,7 +1535,7 @@ bool MovementAction::Flee(Unit* target)
float rx, ry, rz;
if (!manager.CalculateDestination(&rx, &ry, &rz))
{
botAI->TellError("Nowhere to flee");
botAI->TellError("Некуда отступать");
return false;
}
@@ -1940,7 +1940,7 @@ bool AvoidAoeAction::AvoidAuraWithDynamicObj()
lastTellTimer = time(NULL);
lastMoveTimer = getMSTime();
std::ostringstream out;
out << "I'm avoiding " << name.str() << " (" << spellInfo->Id << ")" << " Radius " << radius << " - [Aura]";
out << "Избегаю: " << name.str() << " (" << spellInfo->Id << ")" << "; радиус " << radius << " [аура]";
bot->Say(out.str(), LANG_UNIVERSAL);
}
return true;
@@ -2008,7 +2008,7 @@ bool AvoidAoeAction::AvoidGameObjectWithDamage()
lastTellTimer = time(NULL);
lastMoveTimer = getMSTime();
std::ostringstream out;
out << "I'm avoiding " << name.str() << " (" << spellInfo->Id << ")" << " Radius " << radius
out << "Избегаю: " << name.str() << " (" << spellInfo->Id << ")" << "; радиус " << radius
<< " - [Trap]";
bot->Say(out.str(), LANG_UNIVERSAL);
}
@@ -2075,7 +2075,7 @@ bool AvoidAoeAction::AvoidUnitWithDamageAura()
lastTellTimer = time(NULL);
lastMoveTimer = getMSTime();
std::ostringstream out;
out << "I'm avoiding " << name.str() << " (" << triggerSpellInfo->Id << ")"
out << "Избегаю: " << name.str() << " (" << triggerSpellInfo->Id << ")"
<< " Radius " << radius << " - [Unit Trigger]";
bot->Say(out.str(), LANG_UNIVERSAL);
}
@@ -2554,7 +2554,7 @@ bool DisperseSetAction::Execute(Event event)
if (text == "disable")
{
RESET_AI_VALUE(float, "disperse distance");
botAI->TellMasterNoFacing("Disable disperse");
botAI->TellMasterNoFacing("Рассредоточение отключено");
return true;
}
if (text == "enable" || text == "reset")
@@ -2569,7 +2569,7 @@ bool DisperseSetAction::Execute(Event event)
}
float dis = AI_VALUE(float, "disperse distance");
std::ostringstream out;
out << "Enable disperse distance " << std::setprecision(2) << dis;
out << "Включено рассредоточение с дистанцией " << std::setprecision(2) << dis;
botAI->TellMasterNoFacing(out.str());
return true;
}
@@ -2579,13 +2579,13 @@ bool DisperseSetAction::Execute(Event event)
std::ostringstream out;
if (dis <= 0.0f)
{
out << "Enable disperse first";
out << "Сначала включите рассредоточение";
botAI->TellMasterNoFacing(out.str());
return true;
}
dis += 1.0f;
SET_AI_VALUE(float, "disperse distance", dis);
out << "Increase disperse distance to " << std::setprecision(2) << dis;
out << "Дистанция рассредоточения увеличена до " << std::setprecision(2) << dis;
botAI->TellMasterNoFacing(out.str());
return true;
}
@@ -2599,7 +2599,7 @@ bool DisperseSetAction::Execute(Event event)
}
SET_AI_VALUE(float, "disperse distance", dis);
std::ostringstream out;
out << "Increase disperse distance to " << std::setprecision(2) << dis;
out << "Дистанция рассредоточения увеличена до " << std::setprecision(2) << dis;
botAI->TellMasterNoFacing(out.str());
return true;
}
@@ -2611,18 +2611,18 @@ bool DisperseSetAction::Execute(Event event)
std::ostringstream out;
if (dis < 0 || dis > 100.0f)
{
out << "Invalid disperse distance " << std::setprecision(2) << dis;
out << "Недопустимая дистанция рассредоточения: " << std::setprecision(2) << dis;
}
else
{
SET_AI_VALUE(float, "disperse distance", dis);
out << "Set disperse distance to " << std::setprecision(2) << dis;
out << "Дистанция рассредоточения установлена: " << std::setprecision(2) << dis;
}
botAI->TellMasterNoFacing(out.str());
return true;
}
std::ostringstream out;
out << "Usage: disperse [enable | disable | increase | decrease | set {distance}]";
out << "Использование: disperse [enable | disable | increase | decrease | set {дистанция}]";
float dis = AI_VALUE(float, "disperse distance");
if (dis > 0.0f)
{
@@ -36,6 +36,6 @@ void OpenItemAction::OpenItem(Item* item, uint8 bag, uint8 slot)
botAI->GetAiObjectContext()->GetValue<LootObject>("loot target")->Set(lootObject);
std::ostringstream out;
out << "Opened item: " << item->GetTemplate()->Name1;
out << "Открыт предмет: " << item->GetTemplate()->Name1;
botAI->TellMaster(out.str());
}
@@ -17,9 +17,9 @@ bool OutfitAction::Execute(Event event)
if (param == "?")
{
List();
botAI->TellMaster("outfit <name> +[item] to add items");
botAI->TellMaster("outfit <name> -[item] to remove items");
botAI->TellMaster("outfit <name> equip/replace to equip items");
botAI->TellMaster("outfit <имя> +[предмет] — добавить предметы");
botAI->TellMaster("outfit <имя> -[предмет] — удалить предметы");
botAI->TellMaster("outfit <имя> equip/replace — надеть предметы");
}
else
{
@@ -30,7 +30,7 @@ bool OutfitAction::Execute(Event event)
Save(name, items);
std::ostringstream out;
out << "Setting outfit " << name << " as " << param;
out << "Настраиваю комплект: " << name << " как " << param;
botAI->TellMaster(out);
return true;
}
@@ -48,7 +48,7 @@ bool OutfitAction::Execute(Event event)
if (command == "equip")
{
std::ostringstream out;
out << "Equipping outfit " << name;
out << "Надеваю комплект: " << name;
botAI->TellMaster(out);
EquipItems(outfit);
@@ -57,7 +57,7 @@ bool OutfitAction::Execute(Event event)
else if (command == "replace")
{
std::ostringstream out;
out << "Replacing current equip with outfit " << name;
out << "Заменяю текущую экипировку комплектом: " << name;
botAI->TellMaster(out);
for (uint8 slot = EQUIPMENT_SLOT_START; slot < EQUIPMENT_SLOT_END; slot++)
@@ -82,7 +82,7 @@ bool OutfitAction::Execute(Event event)
else if (command == "reset")
{
std::ostringstream out;
out << "Resetting outfit " << name;
out << "Сбрасываю комплект: " << name;
botAI->TellMaster(out);
Save(name, ItemIds());
@@ -91,7 +91,7 @@ bool OutfitAction::Execute(Event event)
else if (command == "update")
{
std::ostringstream out;
out << "Updating with current items outfit " << name;
out << "Обновляю комплект текущими предметами: " << name;
botAI->TellMaster(out);
Update(name);
@@ -38,7 +38,7 @@ bool PetitionSignAction::Execute(Event event)
if (bot->GetArenaTeamId(slot))
{
// player is already in an arena team
botAI->TellError("Sorry, I am already in such team");
botAI->TellError("У меня уже есть такая команда");
accept = false;
}
}
@@ -46,13 +46,13 @@ bool PetitionSignAction::Execute(Event event)
{
if (bot->GetGuildId())
{
botAI->TellError("Sorry, I am in a guild already");
botAI->TellError("Я уже состою в гильдии");
accept = false;
}
if (bot->GetGuildIdInvited())
{
botAI->TellError("Sorry, I am invited to a guild already");
botAI->TellError("Меня уже пригласили в гильдию");
accept = false;
}
@@ -60,7 +60,7 @@ bool PetitionSignAction::Execute(Event event)
/*if (QueryResult* result = CharacterDatabase.Query("SELECT playerguid FROM petition_sign WHERE player_account =
{} AND petitionguid = {}'", bot->GetSession()->GetAccountId(), petitionGuid.GetCounter()))
{
botAI->TellError("Sorry, I already signed this pettition");
botAI->TellError("Я уже подписал эту хартию");
accept = false;
}
*/
@@ -88,7 +88,7 @@ bool PetitionSignAction::Execute(Event event)
WorldPacket data(CMSG_PETITION_SIGN, 20);
data << petitionGuid << unk;
bot->GetSession()->HandlePetitionSignOpcode(data);
bot->Say("Thanks for the invite!", LANG_UNIVERSAL);
bot->Say("Спасибо за приглашение!", LANG_UNIVERSAL);
LOG_INFO("playerbots", "Bot {} <{}> accepts {} invite", bot->GetGUID().ToString().c_str(),
bot->GetName().c_str(), isArena ? "Arena" : "Guild");
return true;

Some files were not shown because too many files have changed in this diff Show More