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