Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d9e793cac1 | |||
| b6770199c3 | |||
| f06300031e | |||
| 442c708e85 | |||
| 451d783994 | |||
| 1df95daa5a | |||
| f9cb8e6e44 | |||
| a0ec2976ef | |||
| 991afe708a | |||
| 72cede7878 | |||
| deff67efeb | |||
| 7c4b2c77bb | |||
| f04afd14d8 | |||
| 73c4349fa9 | |||
| 86cb8811b1 | |||
| 0c72d04d38 | |||
| 2f1278cb7c | |||
| f8177f5ac4 | |||
| 98858d29b5 | |||
| 334d127e6a | |||
| bca073ef4a | |||
| 423e140f46 | |||
| 2bf075d616 | |||
| b920ec50ab | |||
| e965678adf | |||
| 5918cf7b1a | |||
| e04f0f068c | |||
| 3405057e5b | |||
| 85287568b5 | |||
| 75600c767f | |||
| bfb4aee94b | |||
| 9d7bfe2516 | |||
| b5f3f51208 | |||
| 88ed7f639f | |||
| 889fe02d1b | |||
| 52868e6634 | |||
| a45158f756 | |||
| 4d36c6f702 | |||
| ed017ab51a | |||
| 444b2b9d80 | |||
| 7c092e8a72 |
+22
-16
@@ -183,25 +183,25 @@ ACORE_WORLD_DBC_ENFORCE_ITEM_ATTRIBUTES=0
|
|||||||
ACORE_WORLD_INSTANCE_IGNORE_RAID=1
|
ACORE_WORLD_INSTANCE_IGNORE_RAID=1
|
||||||
|
|
||||||
# Множитель физического урона обычной элиты.
|
# Множитель физического урона обычной элиты.
|
||||||
ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_DAMAGE=0.80
|
ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_DAMAGE=1.00
|
||||||
|
|
||||||
# Множитель физического урона rare.
|
# Множитель физического урона rare.
|
||||||
ACORE_WORLD_RATE_CREATURE_RARE_DAMAGE=0.80
|
ACORE_WORLD_RATE_CREATURE_RARE_DAMAGE=1.00
|
||||||
|
|
||||||
# Множитель физического урона rare elite.
|
# Множитель физического урона rare elite.
|
||||||
ACORE_WORLD_RATE_CREATURE_RAREELITE_DAMAGE=0.75
|
ACORE_WORLD_RATE_CREATURE_RAREELITE_DAMAGE=1.00
|
||||||
|
|
||||||
# Множитель физического урона world boss.
|
# Множитель физического урона world boss.
|
||||||
ACORE_WORLD_RATE_CREATURE_WORLDBOSS_DAMAGE=1
|
ACORE_WORLD_RATE_CREATURE_WORLDBOSS_DAMAGE=1
|
||||||
|
|
||||||
# Множитель spell-урона обычной элиты.
|
# Множитель spell-урона обычной элиты.
|
||||||
ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_SPELL_DAMAGE=0.80
|
ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_SPELL_DAMAGE=1.00
|
||||||
|
|
||||||
# Множитель spell-урона rare.
|
# Множитель spell-урона rare.
|
||||||
ACORE_WORLD_RATE_CREATURE_RARE_SPELL_DAMAGE=0.80
|
ACORE_WORLD_RATE_CREATURE_RARE_SPELL_DAMAGE=1.00
|
||||||
|
|
||||||
# Множитель spell-урона rare elite.
|
# Множитель spell-урона rare elite.
|
||||||
ACORE_WORLD_RATE_CREATURE_RAREELITE_SPELL_DAMAGE=0.75
|
ACORE_WORLD_RATE_CREATURE_RAREELITE_SPELL_DAMAGE=1.00
|
||||||
|
|
||||||
# Множитель spell-урона world boss.
|
# Множитель spell-урона world boss.
|
||||||
ACORE_WORLD_RATE_CREATURE_WORLDBOSS_SPELL_DAMAGE=1
|
ACORE_WORLD_RATE_CREATURE_WORLDBOSS_SPELL_DAMAGE=1
|
||||||
@@ -223,10 +223,10 @@ ACORE_WORLD_RATE_CREATURE_WORLDBOSS_HP=1
|
|||||||
# ------------------------------------------------------------------------------
|
# ------------------------------------------------------------------------------
|
||||||
|
|
||||||
# Минимальное число игроков, до которого скейлится обычный инст.
|
# Минимальное число игроков, до которого скейлится обычный инст.
|
||||||
ACORE_AUTOBALANCE_MIN_PLAYERS=1
|
ACORE_AUTOBALANCE_MIN_PLAYERS=2
|
||||||
|
|
||||||
# Минимальное число игроков для heroic 5ppl.
|
# Минимальное число игроков для heroic 5ppl.
|
||||||
ACORE_AUTOBALANCE_MIN_PLAYERS_HEROIC=1
|
ACORE_AUTOBALANCE_MIN_PLAYERS_HEROIC=2
|
||||||
|
|
||||||
# Минимальное число игроков для рейдов normal.
|
# Минимальное число игроков для рейдов normal.
|
||||||
ACORE_AUTOBALANCE_MIN_PLAYERS_RAID=1
|
ACORE_AUTOBALANCE_MIN_PLAYERS_RAID=1
|
||||||
@@ -234,23 +234,29 @@ ACORE_AUTOBALANCE_MIN_PLAYERS_RAID=1
|
|||||||
# Минимальное число игроков для рейдов heroic.
|
# Минимальное число игроков для рейдов heroic.
|
||||||
ACORE_AUTOBALANCE_MIN_PLAYERS_RAID_HEROIC=1
|
ACORE_AUTOBALANCE_MIN_PLAYERS_RAID_HEROIC=1
|
||||||
|
|
||||||
|
# Насколько уровней ниже игрока могут скейлиться мобы обычного подземелья.
|
||||||
|
ACORE_AUTOBALANCE_LEVEL_SCALING_FLOOR_DUNGEONS=1
|
||||||
|
|
||||||
|
# Насколько уровней ниже игрока могут скейлиться мобы heroic-подземелья.
|
||||||
|
ACORE_AUTOBALANCE_LEVEL_SCALING_FLOOR_HEROIC_DUNGEONS=1
|
||||||
|
|
||||||
# Множитель HP обычных мобов в normal 5ppl.
|
# Множитель HP обычных мобов в normal 5ppl.
|
||||||
ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH=1.30
|
ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH=1.55
|
||||||
|
|
||||||
# Множитель урона обычных мобов в normal 5ppl.
|
# Множитель урона обычных мобов в normal 5ppl.
|
||||||
ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE=1.10
|
ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE=2.00
|
||||||
|
|
||||||
# Множитель HP боссов в normal 5ppl.
|
# Множитель HP боссов в normal 5ppl.
|
||||||
ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH=1.40
|
ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH=2.00
|
||||||
|
|
||||||
# Множитель HP обычных мобов в heroic 5ppl.
|
# Множитель HP обычных мобов в heroic 5ppl.
|
||||||
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_HEALTH=1.25
|
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_HEALTH=1.65
|
||||||
|
|
||||||
# Множитель урона обычных мобов в heroic 5ppl.
|
# Множитель урона обычных мобов в heroic 5ppl.
|
||||||
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_DAMAGE=1.05
|
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_DAMAGE=2.20
|
||||||
|
|
||||||
# Множитель HP боссов в heroic 5ppl.
|
# Множитель HP боссов в heroic 5ppl.
|
||||||
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_HEALTH=1.35
|
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_HEALTH=2.20
|
||||||
|
|
||||||
# Кривая скейла рейдов: чем ниже значение, тем быстрее растет сложность с игроками.
|
# Кривая скейла рейдов: чем ниже значение, тем быстрее растет сложность с игроками.
|
||||||
ACORE_AUTOBALANCE_INFLECTION_POINT_RAID=0.75
|
ACORE_AUTOBALANCE_INFLECTION_POINT_RAID=0.75
|
||||||
@@ -265,10 +271,10 @@ ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC=0.80
|
|||||||
ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC_BOSS_MODIFIER=1.45
|
ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC_BOSS_MODIFIER=1.45
|
||||||
|
|
||||||
# Множитель урона боссов в обычных 5ppl.
|
# Множитель урона боссов в обычных 5ppl.
|
||||||
ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_DAMAGE=1.00
|
ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_DAMAGE=2.50
|
||||||
|
|
||||||
# Множитель урона боссов в heroic 5ppl.
|
# Множитель урона боссов в heroic 5ppl.
|
||||||
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_DAMAGE=0.90
|
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_DAMAGE=2.80
|
||||||
|
|
||||||
# Общий множитель статов рейда normal.
|
# Общий множитель статов рейда normal.
|
||||||
ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_GLOBAL=0.85
|
ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_GLOBAL=0.85
|
||||||
|
|||||||
+2
-1
@@ -124,4 +124,5 @@ __pycache__/
|
|||||||
# !modules/yourmodule
|
# !modules/yourmodule
|
||||||
#
|
#
|
||||||
# ==================
|
# ==================
|
||||||
.claude
|
.claude
|
||||||
|
!modules/AccountBound
|
||||||
|
|||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,49 @@
|
|||||||
|
-- DB update 2026_07_22_00 -> 2026_08_16_00
|
||||||
|
-- MoonWell launcher-only authentication.
|
||||||
|
|
||||||
|
ALTER TABLE `account`
|
||||||
|
ADD COLUMN `session_auth` enum('none','launcher','dev') NOT NULL DEFAULT 'none' AFTER `session_key`;
|
||||||
|
|
||||||
|
UPDATE `account`
|
||||||
|
SET `session_key` = NULL,
|
||||||
|
`session_auth` = 'none';
|
||||||
|
|
||||||
|
CREATE TABLE `launcher_ticket` (
|
||||||
|
`account_id` int unsigned NOT NULL,
|
||||||
|
`generation_id` binary(16) NOT NULL,
|
||||||
|
`srp_salt` binary(32) NOT NULL,
|
||||||
|
`srp_verifier` binary(32) NOT NULL,
|
||||||
|
`client_build` smallint unsigned NOT NULL,
|
||||||
|
`launcher_session_hash` binary(32) DEFAULT NULL,
|
||||||
|
`issued_at` datetime(6) NOT NULL,
|
||||||
|
`expires_at` datetime(6) NOT NULL,
|
||||||
|
PRIMARY KEY (`account_id`),
|
||||||
|
UNIQUE KEY `uq_launcher_ticket_generation` (`generation_id`),
|
||||||
|
KEY `idx_launcher_ticket_expiry` (`expires_at`),
|
||||||
|
CONSTRAINT `fk_launcher_ticket_account`
|
||||||
|
FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE,
|
||||||
|
CONSTRAINT `chk_launcher_ticket_expiry`
|
||||||
|
CHECK (`expires_at` > `issued_at` AND `expires_at` <= `issued_at` + INTERVAL 60 SECOND)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `launcher_ticket_consumption` (
|
||||||
|
`generation_id` binary(16) NOT NULL,
|
||||||
|
`account_id` int unsigned NOT NULL,
|
||||||
|
`claim_nonce` binary(16) NOT NULL,
|
||||||
|
`consumed_at` datetime(6) NOT NULL,
|
||||||
|
PRIMARY KEY (`generation_id`),
|
||||||
|
UNIQUE KEY `uq_launcher_ticket_claim_nonce` (`claim_nonce`),
|
||||||
|
KEY `idx_launcher_ticket_consumption_account` (`account_id`, `consumed_at`),
|
||||||
|
CONSTRAINT `fk_launcher_ticket_consumption_account`
|
||||||
|
FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
|
|
||||||
|
CREATE TABLE `launcher_dev_account` (
|
||||||
|
`account_id` int unsigned NOT NULL,
|
||||||
|
`allowed_ip` varchar(45) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
|
||||||
|
`comment` varchar(255) NOT NULL DEFAULT '',
|
||||||
|
`enabled` tinyint unsigned NOT NULL DEFAULT 1,
|
||||||
|
PRIMARY KEY (`account_id`, `allowed_ip`),
|
||||||
|
CONSTRAINT `fk_launcher_dev_account_account`
|
||||||
|
FOREIGN KEY (`account_id`) REFERENCES `account` (`id`) ON DELETE CASCADE
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
|
||||||
@@ -0,0 +1,371 @@
|
|||||||
|
# Контракт авторизации MoonWell через лаунчер
|
||||||
|
|
||||||
|
Статус документа: обязательный контракт для Release-сборок.
|
||||||
|
|
||||||
|
Ключевые слова **ДОЛЖЕН**, **НЕЛЬЗЯ**, **СЛЕДУЕТ** и **МОЖЕТ** описывают
|
||||||
|
обязательность требований.
|
||||||
|
|
||||||
|
## 1. Цель и границы доверия
|
||||||
|
|
||||||
|
Игровой клиент World of Warcraft 3.3.5a остаётся стандартным SRP6-клиентом,
|
||||||
|
но вместо постоянного пароля получает короткоживущий одноразовый ticket от
|
||||||
|
MoonWell Launcher. Наличие или отсутствие полей логина в GlueXML не является
|
||||||
|
границей безопасности. Решение о допустимости входа всегда принимает
|
||||||
|
`authserver`.
|
||||||
|
|
||||||
|
В поток входят четыре компонента:
|
||||||
|
|
||||||
|
1. backend аутентифицирует пользователя и выпускает game ticket;
|
||||||
|
2. launcher передаёт game account name и ticket только через environment block
|
||||||
|
нового `Wow.exe`;
|
||||||
|
3. MoonWell.dll забирает значения до загрузки GlueXML и инициирует обычный SRP;
|
||||||
|
4. `authserver` использует временные salt/verifier и атомарно погашает ticket
|
||||||
|
после успешного `CMD_AUTH_LOGON_PROOF`.
|
||||||
|
|
||||||
|
## 2. Термины
|
||||||
|
|
||||||
|
- **backend account** — учётная запись пользователя в сервисах MoonWell;
|
||||||
|
- **game account** — имя учётной записи AzerothCore, передаваемое в поле `I`
|
||||||
|
`CMD_AUTH_LOGON_CHALLENGE`;
|
||||||
|
- **launcher session** — аутентифицированная сессия launcher с backend;
|
||||||
|
- **ticket** — одноразовый временный пароль для SRP;
|
||||||
|
- **ticket generation** — уникальный внутренний идентификатор выпуска ticket,
|
||||||
|
используемый для отзыва и атомарного погашения;
|
||||||
|
- **dev account** — явно разрешённая сервером учётная запись с постоянным SRP
|
||||||
|
verifier для локальной разработки.
|
||||||
|
|
||||||
|
## 3. Формат launcher credentials
|
||||||
|
|
||||||
|
Launcher передаёт ровно две обязательные переменные:
|
||||||
|
|
||||||
|
```text
|
||||||
|
MOONWELL_LAUNCH_ACCOUNT=<game account name>
|
||||||
|
MOONWELL_LAUNCH_TICKET=<single-use SRP password>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.1 `MOONWELL_LAUNCH_ACCOUNT`
|
||||||
|
|
||||||
|
- непустая строка печатного ASCII (`0x21..0x7E`), без пробельных символов;
|
||||||
|
- не содержит `NUL`, `=` и управляющих символов;
|
||||||
|
- верхний предел безопасного чтения из environment — 320 байт;
|
||||||
|
- перед SRP приводится к той же канонической форме, что и имя в AzerothCore,
|
||||||
|
то есть к ASCII uppercase;
|
||||||
|
- backend и `authserver` сравнивают каноническое имя, а не display name.
|
||||||
|
|
||||||
|
Ограничение 320 байт является только пределом входного буфера MoonWell.dll.
|
||||||
|
Текущий протокол и этот репозиторий не поддерживают game account такой длины:
|
||||||
|
`I_len` имеет размер `uint8`, `AuthSession.cpp` принимает challenge лишь с
|
||||||
|
добавкой до 16 байт, а `AccountMgr` использует `MAX_ACCOUNT_STR = 17`.
|
||||||
|
Поэтому backend **ДОЛЖЕН** отображать длинную backend account на отдельное
|
||||||
|
короткое game account name, совместимое с текущим сервером (не более 17 байт).
|
||||||
|
Расширение этого лимита является отдельным изменением протокола/сервера и не
|
||||||
|
входит в настоящий контракт.
|
||||||
|
|
||||||
|
### 3.2 `MOONWELL_LAUNCH_TICKET`
|
||||||
|
|
||||||
|
- ровно 16 ASCII-символов;
|
||||||
|
- допустимый алфавит: `A-Z` и `0-9`;
|
||||||
|
- генерируется CSPRNG с равномерным rejection sampling;
|
||||||
|
- энтропия полного пространства `36^16` — около 82,7 бит;
|
||||||
|
- срок действия: не более 60 секунд от момента выпуска;
|
||||||
|
- одноразовый;
|
||||||
|
- новый выпуск атомарно отзывает предыдущий активный ticket game account.
|
||||||
|
|
||||||
|
Ни один компонент **НЕ ДОЛЖЕН** нормализовать, менять регистр или обрезать
|
||||||
|
ticket. Для SRP ticket используется в точности как получен.
|
||||||
|
|
||||||
|
## 4. Контракт backend
|
||||||
|
|
||||||
|
Конкретный HTTP route может определяться backend, но операция выпуска имеет
|
||||||
|
следующую семантику:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"launcher_session_id": "opaque launcher session identifier",
|
||||||
|
"client_build": 12340
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Успешный ответ:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"account": "GAMEACCOUNT",
|
||||||
|
"ticket": "7Q9AV4R2M8ZK3W1P",
|
||||||
|
"expires_at": "2026-08-16T12:34:56Z"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Требования к операции:
|
||||||
|
|
||||||
|
- запрос разрешён только из действующей аутентифицированной launcher session;
|
||||||
|
- требуемая пользователю MFA завершается до выпуска ticket;
|
||||||
|
- backend проверяет право launcher session входить в указанный game account;
|
||||||
|
- `client_build` должен входить в разрешённый список;
|
||||||
|
- выпуск и отзыв предыдущей generation выполняются одной транзакцией;
|
||||||
|
- ответ передаётся только по TLS и содержит `Cache-Control: no-store`;
|
||||||
|
- ticket не возвращается повторно и не попадает в URL, telemetry, analytics,
|
||||||
|
tracing, audit payload, exception text или access log;
|
||||||
|
- rate limit применяется как минимум к пользователю, game account, launcher
|
||||||
|
session и IP;
|
||||||
|
- время жизни вычисляется по серверному времени. Часы клиента не являются
|
||||||
|
источником истины.
|
||||||
|
|
||||||
|
Рекомендуемые машинные ошибки: `UNAUTHENTICATED`, `ACCOUNT_NOT_ALLOWED`,
|
||||||
|
`CLIENT_BUILD_NOT_ALLOWED`, `RATE_LIMITED`, `GAME_ACCOUNT_NOT_COMPATIBLE` и
|
||||||
|
`INTERNAL_ERROR`. Ошибка не должна содержать ticket или verifier.
|
||||||
|
|
||||||
|
## 5. Хранение ticket
|
||||||
|
|
||||||
|
Предпочтительный вариант — не хранить открытый ticket. Backend вычисляет
|
||||||
|
временные SRP salt и verifier тем же кодом/алгоритмом, что
|
||||||
|
`Acore::Crypto::SRP6::MakeRegistrationData(canonicalAccount, ticket)`, сохраняет
|
||||||
|
их вместе с generation и уничтожает открытый ticket после формирования ответа.
|
||||||
|
|
||||||
|
Минимальная логическая запись:
|
||||||
|
|
||||||
|
```text
|
||||||
|
account_id UNIQUE
|
||||||
|
generation_id UNIQUE, unpredictable
|
||||||
|
srp_salt BINARY(32)
|
||||||
|
srp_verifier BINARY(32)
|
||||||
|
launcher_session_hash nullable/optional binding
|
||||||
|
client_build required binding
|
||||||
|
issued_at server timestamp
|
||||||
|
expires_at server timestamp, <= issued_at + 60 seconds
|
||||||
|
consumed_at nullable
|
||||||
|
revoked_at nullable
|
||||||
|
```
|
||||||
|
|
||||||
|
Активна только запись, для которой одновременно выполнено:
|
||||||
|
|
||||||
|
```text
|
||||||
|
consumed_at IS NULL
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND expires_at > server_now
|
||||||
|
```
|
||||||
|
|
||||||
|
Если архитектура временно требует хранения открытого ticket, он должен быть
|
||||||
|
зашифрован отдельным ротируемым ключом, недоступным read-only потребителям БД,
|
||||||
|
и удалён при погашении/истечении. Хеш ticket сам по себе недостаточен для
|
||||||
|
построения SRP verifier после выпуска.
|
||||||
|
|
||||||
|
## 6. Запуск `Wow.exe`
|
||||||
|
|
||||||
|
Launcher **ДОЛЖЕН**:
|
||||||
|
|
||||||
|
1. получить ticket непосредственно перед запуском;
|
||||||
|
2. построить отдельный Unicode environment block для дочернего процесса,
|
||||||
|
сохранив необходимые штатные переменные и добавив две MoonWell-переменные;
|
||||||
|
3. вызвать `CreateProcessW` с `CREATE_UNICODE_ENVIRONMENT`;
|
||||||
|
4. не добавлять credentials в command line;
|
||||||
|
5. сразу после возврата `CreateProcessW` затереть все собственные изменяемые
|
||||||
|
буферы с ticket через гарантированно не оптимизируемую операцию, например
|
||||||
|
`SecureZeroMemory`;
|
||||||
|
6. уничтожить созданный environment block независимо от результата запуска.
|
||||||
|
|
||||||
|
Launcher **НЕ ДОЛЖЕН** менять собственное глобальное окружение через
|
||||||
|
`SetEnvironmentVariable`, поскольку это создаёт окно утечки и гонки при
|
||||||
|
параллельных запусках. Переменные должны существовать только в явно собранном
|
||||||
|
environment block конкретного дочернего процесса.
|
||||||
|
|
||||||
|
Ticket нельзя помещать в аргументы, файлы, реестр, crash metadata или логи.
|
||||||
|
Дамп памяти процесса остаётся привилегированной атакой; MoonWell.dll должна
|
||||||
|
минимизировать время жизни plaintext и очищать временные буферы.
|
||||||
|
|
||||||
|
## 7. Контракт MoonWell.dll и GlueXML
|
||||||
|
|
||||||
|
MoonWell.dll до загрузки GlueXML:
|
||||||
|
|
||||||
|
1. читает обе launcher-переменные в ограниченные изменяемые буферы;
|
||||||
|
2. немедленно удаляет их из окружения `Wow.exe`;
|
||||||
|
3. валидирует обе переменные целиком;
|
||||||
|
4. передаёт валидную пару GlueXML только в памяти процесса;
|
||||||
|
5. очищает исходные и промежуточные буферы после начала SRP-входа.
|
||||||
|
|
||||||
|
Пара считается валидной только если присутствуют и корректны обе переменные.
|
||||||
|
Частичная, пустая, слишком длинная или синтаксически неверная пара полностью
|
||||||
|
отбрасывается. Нельзя переходить к ручному логину из-за ошибки credentials в
|
||||||
|
Release.
|
||||||
|
|
||||||
|
Состояния интерфейса:
|
||||||
|
|
||||||
|
| Сборка/вход | `AccountLoginUI` | Основное действие |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Release, валидная пара | скрыт | один вызов `DefaultServerLogin(account, ticket)` |
|
||||||
|
| Release, пары нет/она невалидна | поля, password и save options скрыты | открыть launcher |
|
||||||
|
| Debug и `MOONWELL_DEV_LOGIN=1` | прежняя форма видима | штатный ручной SRP-вход |
|
||||||
|
| Debug без dev-флага | как Release | открыть launcher |
|
||||||
|
|
||||||
|
При каждом не-dev запуске клиент удаляет ранее сохранённые account/password и
|
||||||
|
отключает параметры их сохранения до отображения UI. Повторный автоматический
|
||||||
|
вызов `DefaultServerLogin` в рамках одного процесса запрещён.
|
||||||
|
|
||||||
|
`MOONWELL_DEV_LOGIN` читается и удаляется тем же ранним нативным кодом. Он
|
||||||
|
учитывается только Debug-сборкой MoonWell.dll; Release-сборка всегда его
|
||||||
|
игнорирует.
|
||||||
|
|
||||||
|
## 8. Открытие launcher
|
||||||
|
|
||||||
|
Кнопка «Авторизоваться»:
|
||||||
|
|
||||||
|
1. ищет `MoonWellLauncher.exe` в каталоге фактически запущенного `Wow.exe`;
|
||||||
|
2. если файл найден, запускает его без credentials;
|
||||||
|
3. иначе открывает `moonwell://authorize?source=client` системным обработчиком.
|
||||||
|
|
||||||
|
Путь нельзя брать из current working directory. Перед запуском следует
|
||||||
|
проверять издателя Authenticode/ожидаемую подпись launcher.
|
||||||
|
|
||||||
|
Установщик регистрирует URI scheme только для текущего пользователя в
|
||||||
|
`HKCU\Software\Classes\moonwell`. Команда обработчика должна корректно заключать
|
||||||
|
путь executable и аргумент URI в кавычки. Launcher рассматривает URI как
|
||||||
|
недоверенный ввод, разрешает только известные host/action/query keys и никогда
|
||||||
|
не принимает credentials через URI.
|
||||||
|
|
||||||
|
## 9. Обязательный контракт `authserver`
|
||||||
|
|
||||||
|
### 9.1 `CMD_AUTH_LOGON_CHALLENGE`
|
||||||
|
|
||||||
|
После обычного разбора и канонизации game account сервер:
|
||||||
|
|
||||||
|
1. загружает account, блокировки и режим авторизации;
|
||||||
|
2. для обычного account ищет активную launcher ticket generation;
|
||||||
|
3. проверяет expiry, `client_build` и дополнительные привязки;
|
||||||
|
4. если активной generation нет, возвращает общий отказ до обращения к
|
||||||
|
постоянным account salt/verifier;
|
||||||
|
5. сохраняет `generation_id` в объекте конкретной `AuthSession`;
|
||||||
|
6. строит SRP challenge только из временных salt/verifier этой generation.
|
||||||
|
|
||||||
|
Для launcher-only account SRP challenge не должен запрашивать интерактивный
|
||||||
|
TOTP/security token: MFA является свойством launcher/backend-сессии и должна
|
||||||
|
быть завершена до выпуска ticket. Иначе скрытый GlueXML не сможет закончить
|
||||||
|
автоматический вход. Штатный AzerothCore TOTP может оставаться только в явно
|
||||||
|
описанном dev-потоке с видимой формой.
|
||||||
|
|
||||||
|
Для отсутствующего, истёкшего, отозванного или уже использованного ticket
|
||||||
|
следует возвращать одинаковый протокольный результат, например
|
||||||
|
`WOW_FAIL_UNKNOWN_ACCOUNT`, чтобы не создавать oracle состояния ticket.
|
||||||
|
|
||||||
|
### 9.2 `CMD_AUTH_LOGON_PROOF`
|
||||||
|
|
||||||
|
После успешной криптографической проверки proof, TOTP (если применяется) и
|
||||||
|
версии клиента сервер **ДОЛЖЕН** в одной транзакции:
|
||||||
|
|
||||||
|
1. выполнить условное погашение именно generation из текущей `AuthSession`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE launcher_ticket
|
||||||
|
SET consumed_at = CURRENT_TIMESTAMP(6)
|
||||||
|
WHERE account_id = ?
|
||||||
|
AND generation_id = ?
|
||||||
|
AND consumed_at IS NULL
|
||||||
|
AND revoked_at IS NULL
|
||||||
|
AND expires_at > CURRENT_TIMESTAMP(6);
|
||||||
|
```
|
||||||
|
|
||||||
|
2. убедиться, что изменена ровно одна строка;
|
||||||
|
3. сохранить новую game session key и штатные login metadata;
|
||||||
|
4. зафиксировать транзакцию;
|
||||||
|
5. только после commit отправить клиенту успешный `AUTH_LOGON_PROOF`.
|
||||||
|
|
||||||
|
Если условное погашение изменило ноль строк или commit не удался, сервер
|
||||||
|
возвращает общий отказ и не создаёт авторизованную game session. Это обеспечивает
|
||||||
|
отказ повторному proof даже если два challenge успели получить одну generation.
|
||||||
|
|
||||||
|
Неуспешный proof не погашает ticket, но существующие механизмы rate limit и
|
||||||
|
защиты от перебора продолжают действовать. Ticket всё равно истекает не позднее
|
||||||
|
60 секунд после выпуска.
|
||||||
|
|
||||||
|
### 9.3 Постоянный verifier и dev-доступ
|
||||||
|
|
||||||
|
Для обычных accounts `authserver` никогда не выбирает и не проверяет постоянные
|
||||||
|
`account.salt/account.verifier`. При миграции их следует сделать nullable и
|
||||||
|
удалить у launcher-only accounts либо заменить отдельным режимом хранения,
|
||||||
|
который невозможно случайно использовать в launcher-ветке.
|
||||||
|
|
||||||
|
Ручной SRP разрешён только если серверная политика независимо подтверждает все
|
||||||
|
условия:
|
||||||
|
|
||||||
|
- account присутствует в явном dev allowlist;
|
||||||
|
- account имеет режим `DEV_SRP`;
|
||||||
|
- источник соответствует VPN/IP allowlist;
|
||||||
|
- production-конфигурация не разрешает dev-режим по умолчанию.
|
||||||
|
|
||||||
|
Клиентский Debug-флаг не передаётся authserver и не участвует в решении.
|
||||||
|
|
||||||
|
### 9.4 Reconnect
|
||||||
|
|
||||||
|
`CMD_AUTH_RECONNECT_*` может использовать session key только от ранее успешно
|
||||||
|
погашенного ticket или разрешённого dev-входа. При включённом reconnect сервер
|
||||||
|
должен хранить происхождение/время session key и отклонить legacy session keys,
|
||||||
|
созданные до внедрения этой политики. Reconnect не оживляет ticket и не позволяет
|
||||||
|
новому процессу выполнить новый logon без нового ticket.
|
||||||
|
|
||||||
|
## 10. Состояния ticket и гонки
|
||||||
|
|
||||||
|
Допустимые переходы:
|
||||||
|
|
||||||
|
```text
|
||||||
|
ISSUED -> CONSUMED
|
||||||
|
ISSUED -> REVOKED (выпущена новая generation)
|
||||||
|
ISSUED -> EXPIRED (server_now >= expires_at)
|
||||||
|
```
|
||||||
|
|
||||||
|
`CONSUMED`, `REVOKED` и `EXPIRED` — терминальные состояния. Cleanup может
|
||||||
|
физически удалить терминальные записи позже, но не должен превращать их обратно
|
||||||
|
в активные.
|
||||||
|
|
||||||
|
Выдача новой generation не обязана завершать уже созданную игровую сессию, но
|
||||||
|
обязана сделать proof старой незавершённой generation невозможным. Это
|
||||||
|
обеспечивается сравнением `generation_id` при погашении.
|
||||||
|
|
||||||
|
## 11. Логи и наблюдаемость
|
||||||
|
|
||||||
|
Разрешено логировать только:
|
||||||
|
|
||||||
|
- внутренний account id либо необратимо псевдонимизированный идентификатор;
|
||||||
|
- generation id в хешированном/усечённом виде;
|
||||||
|
- тип события: issued, rejected, consumed, revoked, expired;
|
||||||
|
- код результата, build и тайминги без секретов.
|
||||||
|
|
||||||
|
Запрещено логировать ticket, SRP verifier, environment block, полный launcher
|
||||||
|
session token и HTTP response body. Фильтрация секретов должна применяться также
|
||||||
|
к debug-логам, SQL tracing, crash reports и APM.
|
||||||
|
|
||||||
|
## 12. Критерии приёмки
|
||||||
|
|
||||||
|
Реализация считается соответствующей контракту, если автоматические или
|
||||||
|
интеграционные тесты подтверждают:
|
||||||
|
|
||||||
|
1. валидный свежий ticket даёт ровно один успешный вход;
|
||||||
|
2. повторный proof и второй процесс с тем же ticket получают отказ;
|
||||||
|
3. новый ticket отзывает предыдущий, включая уже полученный старый challenge;
|
||||||
|
4. ticket старше 60 секунд отклоняется по серверному времени;
|
||||||
|
5. прямой Release-запуск не показывает и не использует ручные credentials;
|
||||||
|
6. одна отсутствующая/невалидная environment-переменная не запускает SRP;
|
||||||
|
7. переменные удалены из окружения до GlueXML и отсутствуют в дочерних
|
||||||
|
процессах, созданных позже;
|
||||||
|
8. Release игнорирует `MOONWELL_DEV_LOGIN=1`;
|
||||||
|
9. Debug dev-login работает только для server-side allowlisted account и
|
||||||
|
разрешённого источника;
|
||||||
|
10. обычный account с корректным старым постоянным паролем получает отказ;
|
||||||
|
11. ticket, URL и command line отсутствуют во всех проверяемых логах;
|
||||||
|
12. параллельные proof одной generation дают ровно один success;
|
||||||
|
13. запрещённый client build и несоответствующая launcher-session binding дают
|
||||||
|
общий отказ без fallback на постоянный verifier;
|
||||||
|
14. legacy session keys не позволяют обойти launcher-only политику reconnect.
|
||||||
|
15. launcher-only challenge не требует интерактивного TOTP после запуска
|
||||||
|
`DefaultServerLogin`.
|
||||||
|
|
||||||
|
## 13. Локальный запуск разработчика
|
||||||
|
|
||||||
|
Поддерживаемая команда:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
.\run.ps1 -Env local -DeveloperLogin
|
||||||
|
```
|
||||||
|
|
||||||
|
Скрипт собирает Debug runtime, формирует отдельный environment block и добавляет
|
||||||
|
`MOONWELL_DEV_LOGIN=1` только запускаемому процессу. Он не изменяет глобальное
|
||||||
|
окружение пользователя и не делает server-side account dev-разрешённым.
|
||||||
|
Debug DLL не включается в пользовательские Release-дистрибутивы.
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
# Эксплуатация launcher-авторизации
|
||||||
|
|
||||||
|
Нормативный протокол описан в
|
||||||
|
[`LauncherAuthorizationContract.md`](LauncherAuthorizationContract.md). Этот
|
||||||
|
документ относится к реализованной в данном репозитории серверной части.
|
||||||
|
|
||||||
|
## Что входит в репозиторий
|
||||||
|
|
||||||
|
- `authserver` использует временные SRP salt/verifier для всех обычных
|
||||||
|
аккаунтов и не делает fallback на постоянный пароль;
|
||||||
|
- SQL update `2026_08_16_00.sql` добавляет активные ticket, журнал погашений,
|
||||||
|
точный IP allowlist для dev-аккаунтов и provenance session key;
|
||||||
|
- `issue-launcher-ticket.sh` является reference adapter для backend и локальной
|
||||||
|
интеграции. Production backend должен реализовать ту же операцию внутри
|
||||||
|
своего доверенного процесса, включая аутентификацию launcher session и rate
|
||||||
|
limiting;
|
||||||
|
- reconnect и вход в worldserver разрешены только session key с provenance
|
||||||
|
`launcher` или `dev`.
|
||||||
|
|
||||||
|
Исходников MoonWell Launcher, MoonWell.dll, GlueXML и backend в этом
|
||||||
|
репозитории нет. Их реализация должна следовать отдельным разделам контракта.
|
||||||
|
|
||||||
|
## Выпуск ticket
|
||||||
|
|
||||||
|
База должна быть запущена и обновлена штатным `ac-db-import`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./issue-launcher-ticket.sh PLAYER --build 12340 --ttl 60
|
||||||
|
```
|
||||||
|
|
||||||
|
Успешный результат — одна JSON-строка в stdout. Она содержит plaintext ticket
|
||||||
|
и должна быть сразу передана launcher по TLS с `Cache-Control: no-store`.
|
||||||
|
Диагностика выводится в stderr. Новый вызов для того же account атомарно
|
||||||
|
заменяет предыдущую активную generation.
|
||||||
|
|
||||||
|
Скрипт не является публичным HTTP endpoint. Нельзя предоставлять игрокам
|
||||||
|
доступ к его запуску или к MySQL credentials.
|
||||||
|
|
||||||
|
## Dev allowlist
|
||||||
|
|
||||||
|
Постоянный verifier используется только при совпадении account и точного
|
||||||
|
remote IP с активной строкой allowlist:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO launcher_dev_account (account_id, allowed_ip, comment)
|
||||||
|
SELECT id, '10.20.30.40', 'local developer over VPN'
|
||||||
|
FROM account
|
||||||
|
WHERE username = 'DEVACCOUNT'
|
||||||
|
ON DUPLICATE KEY UPDATE enabled = 1, comment = VALUES(comment);
|
||||||
|
```
|
||||||
|
|
||||||
|
Отключение:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE launcher_dev_account
|
||||||
|
SET enabled = 0
|
||||||
|
WHERE account_id = (SELECT id FROM account WHERE username = 'DEVACCOUNT')
|
||||||
|
AND allowed_ip = '10.20.30.40';
|
||||||
|
```
|
||||||
|
|
||||||
|
CIDR и wildcard намеренно не поддерживаются: allowlist требует точного IP.
|
||||||
|
|
||||||
|
## Миграция и откат
|
||||||
|
|
||||||
|
SQL update очищает существующие `account.session_key`, чтобы legacy reconnect
|
||||||
|
не обходил launcher-only политику. После применения старые клиенты должны
|
||||||
|
получить новый launcher ticket.
|
||||||
|
|
||||||
|
Миграция безопасности намеренно не имеет автоматического fallback. Перед
|
||||||
|
развёртыванием должны быть готовы backend issuer и Release-клиент. Для
|
||||||
|
аварийного dev-доступа заранее добавьте только выделенный account и VPN IP в
|
||||||
|
`launcher_dev_account`.
|
||||||
|
|
||||||
|
## Проверка
|
||||||
|
|
||||||
|
После сборки и применения update:
|
||||||
|
|
||||||
|
1. выпустить ticket для тестового account;
|
||||||
|
2. убедиться, что прямой вход со старым паролем отвергнут;
|
||||||
|
3. войти с ticket;
|
||||||
|
4. убедиться, что строка удалена из `launcher_ticket` и появилась в
|
||||||
|
`launcher_ticket_consumption`;
|
||||||
|
5. повторить вход с тем же ticket и получить отказ;
|
||||||
|
6. выпустить два ticket подряд и убедиться, что работает только второй.
|
||||||
@@ -48,6 +48,9 @@ services:
|
|||||||
- ./modules:/azerothcore/modules:ro
|
- ./modules:/azerothcore/modules:ro
|
||||||
- ./lua_scripts:/azerothcore/lua_scripts
|
- ./lua_scripts:/azerothcore/lua_scripts
|
||||||
- ./client_patch/enUS/DBFilesClient/LFGDungeons.dbc:/azerothcore/env/dist/data/dbc/LFGDungeons.dbc:ro
|
- ./client_patch/enUS/DBFilesClient/LFGDungeons.dbc:/azerothcore/env/dist/data/dbc/LFGDungeons.dbc:ro
|
||||||
|
- ./client_patch/enUS/DBFilesClient/BarberShopStyle.dbc:/azerothcore/env/dist/data/dbc/BarberShopStyle.dbc:ro
|
||||||
|
- ./client_patch/enUS/DBFilesClient/CreatureDisplayInfoExtra.dbc:/azerothcore/env/dist/data/dbc/CreatureDisplayInfoExtra.dbc:ro
|
||||||
|
- ./client_patch/enUS/DBFilesClient/SpellVisualKitModelAttach.dbc:/azerothcore/env/dist/data/dbc/SpellVisualKitModelAttach.dbc:ro
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
playerbots-ai-cache:
|
playerbots-ai-cache:
|
||||||
|
|||||||
+1233
-1623
File diff suppressed because it is too large
Load Diff
Generated
+29470
-12006
File diff suppressed because it is too large
Load Diff
+21
-1
@@ -156,7 +156,7 @@ import_mythicplus_sql() {
|
|||||||
log "repairing MythicPlus world bootstrap entries"
|
log "repairing MythicPlus world bootstrap entries"
|
||||||
mysql_exec "acore_world" "
|
mysql_exec "acore_world" "
|
||||||
DELETE FROM creature WHERE id = 900001;
|
DELETE FROM creature WHERE id = 900001;
|
||||||
DELETE FROM gameobject WHERE id = 900000;
|
DELETE FROM gameobject WHERE id1 = 900000;
|
||||||
DELETE FROM creature_template_locale WHERE entry = 900001;
|
DELETE FROM creature_template_locale WHERE entry = 900001;
|
||||||
DELETE FROM item_template_locale WHERE ID = 900100;
|
DELETE FROM item_template_locale WHERE ID = 900100;
|
||||||
DELETE FROM creature_template_model WHERE CreatureID = 900001;
|
DELETE FROM creature_template_model WHERE CreatureID = 900001;
|
||||||
@@ -279,6 +279,16 @@ import_playerbots_ru_sql() {
|
|||||||
import_sql_file "acore_characters" "$names_sql_file"
|
import_sql_file "acore_characters" "$names_sql_file"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import_transmog_sql() {
|
||||||
|
log "Started importing Transmogrification SQL files"
|
||||||
|
|
||||||
|
local account_transmog_sql="$ROOT_DIR/sql/Transmogrification/auth.sql"
|
||||||
|
local character_transmog_sql="$ROOT_DIR/sql/Transmogrification/char.sql"
|
||||||
|
|
||||||
|
maybe_import_table "acore_auth" "account_transmog" "$account_transmog_sql"
|
||||||
|
maybe_import_table "acore_characters" "character_transmog" "$character_transmog_sql"
|
||||||
|
}
|
||||||
|
|
||||||
configure_server_motd() {
|
configure_server_motd() {
|
||||||
log "configuring MoonWell login message"
|
log "configuring MoonWell login message"
|
||||||
mysql_exec "acore_auth" "
|
mysql_exec "acore_auth" "
|
||||||
@@ -331,6 +341,14 @@ import_encounter_journal_sql() {
|
|||||||
fi
|
fi
|
||||||
}
|
}
|
||||||
|
|
||||||
|
import_premium_sql() {
|
||||||
|
log "Started importing Premium SQL files"
|
||||||
|
|
||||||
|
local world_premium_sql="$ROOT_DIR/sql/Premium/world.sql"
|
||||||
|
|
||||||
|
import_sql_file "acore_world" "$world_premium_sql"
|
||||||
|
}
|
||||||
|
|
||||||
if (($# > 0)); then
|
if (($# > 0)); then
|
||||||
case "$1" in
|
case "$1" in
|
||||||
-h|--help)
|
-h|--help)
|
||||||
@@ -362,6 +380,8 @@ import_aoe_loot_sql
|
|||||||
import_playerbots_ru_sql
|
import_playerbots_ru_sql
|
||||||
import_encounter_journal_sql
|
import_encounter_journal_sql
|
||||||
import_store_sql
|
import_store_sql
|
||||||
|
import_transmog_sql
|
||||||
|
import_premium_sql
|
||||||
configure_server_motd
|
configure_server_motd
|
||||||
|
|
||||||
log "custom SQL bootstrap completed"
|
log "custom SQL bootstrap completed"
|
||||||
|
|||||||
@@ -0,0 +1,167 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
ENV_FILE="${ENV_FILE:-$ROOT_DIR/.env}"
|
||||||
|
CLIENT_BUILD=""
|
||||||
|
TTL_SECONDS=60
|
||||||
|
|
||||||
|
if [[ -f "$ENV_FILE" ]]; then
|
||||||
|
set -a
|
||||||
|
# shellcheck disable=SC1090
|
||||||
|
source "$ENV_FILE"
|
||||||
|
set +a
|
||||||
|
fi
|
||||||
|
|
||||||
|
CLIENT_BUILD="${ACORE_REALMLIST_GAMEBUILD:-12340}"
|
||||||
|
|
||||||
|
usage() {
|
||||||
|
cat <<EOF
|
||||||
|
Usage: $(basename "$0") <game-account> [--build <client-build>] [--ttl <1-60>]
|
||||||
|
|
||||||
|
Issues one single-use MoonWell launcher ticket and revokes the account's
|
||||||
|
previous active ticket. The JSON response containing the ticket is written
|
||||||
|
only to stdout; diagnostics go to stderr.
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
|
if (($# == 0)) || [[ "${1:-}" == "-h" || "${1:-}" == "--help" ]]; then
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACCOUNT="$1"
|
||||||
|
shift
|
||||||
|
|
||||||
|
while (($#)); do
|
||||||
|
case "$1" in
|
||||||
|
--build)
|
||||||
|
[[ $# -ge 2 ]] || { printf '%s\n' '--build requires a value' >&2; exit 1; }
|
||||||
|
CLIENT_BUILD="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--ttl)
|
||||||
|
[[ $# -ge 2 ]] || { printf '%s\n' '--ttl requires a value' >&2; exit 1; }
|
||||||
|
TTL_SECONDS="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
-h|--help)
|
||||||
|
usage
|
||||||
|
exit 0
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
printf 'Unknown option: %s\n' "$1" >&2
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if ! [[ "$CLIENT_BUILD" =~ ^[0-9]+$ ]] || ((CLIENT_BUILD < 1 || CLIENT_BUILD > 65535)); then
|
||||||
|
printf '%s\n' 'client build must be an integer from 1 to 65535' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! [[ "$TTL_SECONDS" =~ ^[0-9]+$ ]] || ((TTL_SECONDS < 1 || TTL_SECONDS > 60)); then
|
||||||
|
printf '%s\n' 'ttl must be an integer from 1 to 60 seconds' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export MOONWELL_ISSUER_ACCOUNT="$ACCOUNT"
|
||||||
|
mapfile -t ACCOUNT_DATA < <(python3 <<'PY'
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
|
||||||
|
account = os.environ["MOONWELL_ISSUER_ACCOUNT"]
|
||||||
|
account = "".join(chr(ord(ch) - 32) if "a" <= ch <= "z" else ch for ch in account)
|
||||||
|
encoded = account.encode("ascii", "strict")
|
||||||
|
|
||||||
|
if not encoded or len(encoded) > 17:
|
||||||
|
raise SystemExit("game account must contain 1 to 17 ASCII bytes")
|
||||||
|
if any(byte < 0x21 or byte > 0x7E for byte in encoded) or b"=" in encoded:
|
||||||
|
raise SystemExit("game account must be printable ASCII without whitespace or '='")
|
||||||
|
|
||||||
|
print(account)
|
||||||
|
print(encoded.hex().upper())
|
||||||
|
print(json.dumps(account, ensure_ascii=True))
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
unset MOONWELL_ISSUER_ACCOUNT ACCOUNT
|
||||||
|
|
||||||
|
CANONICAL_ACCOUNT="${ACCOUNT_DATA[0]}"
|
||||||
|
ACCOUNT_HEX="${ACCOUNT_DATA[1]}"
|
||||||
|
ACCOUNT_JSON="${ACCOUNT_DATA[2]}"
|
||||||
|
unset ACCOUNT_DATA
|
||||||
|
|
||||||
|
cd "$ROOT_DIR"
|
||||||
|
|
||||||
|
if ! docker compose ps --status running --services | grep -qx 'ac-database'; then
|
||||||
|
printf '%s\n' 'ac-database is not running; start it with ./start-server.sh' >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
ACCOUNT_ID="$(docker compose exec -T ac-database bash -lc \
|
||||||
|
'mysql -N -B -uroot -p"$MYSQL_ROOT_PASSWORD" acore_auth' \
|
||||||
|
<<<"SELECT id FROM account WHERE username = CONVERT(0x$ACCOUNT_HEX USING ascii) LIMIT 1;")"
|
||||||
|
|
||||||
|
if ! [[ "$ACCOUNT_ID" =~ ^[0-9]+$ ]]; then
|
||||||
|
printf 'game account does not exist: %s\n' "$CANONICAL_ACCOUNT" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
export MOONWELL_ISSUER_CANONICAL_ACCOUNT="$CANONICAL_ACCOUNT"
|
||||||
|
mapfile -t TICKET_DATA < <(python3 <<'PY'
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
import secrets
|
||||||
|
|
||||||
|
alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
|
||||||
|
account = os.environ["MOONWELL_ISSUER_CANONICAL_ACCOUNT"]
|
||||||
|
ticket = "".join(secrets.choice(alphabet) for _ in range(16))
|
||||||
|
generation = secrets.token_bytes(16)
|
||||||
|
salt = secrets.token_bytes(32)
|
||||||
|
|
||||||
|
n = int("894B645E89E1535BBDAD5B8B290650530801B18EBFBF5E8FAB3C82872A3E9BB7", 16)
|
||||||
|
inner = hashlib.sha1(f"{account}:{ticket}".encode("ascii")).digest()
|
||||||
|
x = hashlib.sha1(salt + inner).digest()
|
||||||
|
verifier = pow(7, int.from_bytes(x, "little"), n).to_bytes(32, "little")
|
||||||
|
|
||||||
|
print(generation.hex().upper())
|
||||||
|
print(salt.hex().upper())
|
||||||
|
print(verifier.hex().upper())
|
||||||
|
print(ticket)
|
||||||
|
PY
|
||||||
|
)
|
||||||
|
unset MOONWELL_ISSUER_CANONICAL_ACCOUNT
|
||||||
|
|
||||||
|
GENERATION_HEX="${TICKET_DATA[0]}"
|
||||||
|
SALT_HEX="${TICKET_DATA[1]}"
|
||||||
|
VERIFIER_HEX="${TICKET_DATA[2]}"
|
||||||
|
TICKET="${TICKET_DATA[3]}"
|
||||||
|
unset TICKET_DATA
|
||||||
|
|
||||||
|
SQL="INSERT INTO launcher_ticket
|
||||||
|
(account_id, generation_id, srp_salt, srp_verifier, client_build, issued_at, expires_at)
|
||||||
|
VALUES
|
||||||
|
($ACCOUNT_ID, UNHEX('$GENERATION_HEX'), UNHEX('$SALT_HEX'), UNHEX('$VERIFIER_HEX'),
|
||||||
|
$CLIENT_BUILD, UTC_TIMESTAMP(6), UTC_TIMESTAMP(6) + INTERVAL $TTL_SECONDS SECOND)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
generation_id = VALUES(generation_id),
|
||||||
|
srp_salt = VALUES(srp_salt),
|
||||||
|
srp_verifier = VALUES(srp_verifier),
|
||||||
|
client_build = VALUES(client_build),
|
||||||
|
launcher_session_hash = NULL,
|
||||||
|
issued_at = VALUES(issued_at),
|
||||||
|
expires_at = VALUES(expires_at);
|
||||||
|
SELECT DATE_FORMAT(expires_at, '%Y-%m-%dT%H:%i:%sZ')
|
||||||
|
FROM launcher_ticket
|
||||||
|
WHERE account_id = $ACCOUNT_ID;"
|
||||||
|
|
||||||
|
EXPIRES_AT="$(docker compose exec -T ac-database bash -lc \
|
||||||
|
'mysql -N -B -uroot -p"$MYSQL_ROOT_PASSWORD" acore_auth' <<<"$SQL")"
|
||||||
|
|
||||||
|
printf '{"account":%s,"ticket":"%s","expires_at":"%s","client_build":%s}\n' \
|
||||||
|
"$ACCOUNT_JSON" "$TICKET" "$EXPIRES_AT" "$CLIENT_BUILD"
|
||||||
|
|
||||||
|
TICKET="$(printf '%*s' 16 '')"
|
||||||
|
unset TICKET SQL GENERATION_HEX SALT_HEX VERIFIER_HEX ACCOUNT_HEX CANONICAL_ACCOUNT ACCOUNT_JSON
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
return {}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
local PLAYER_EVENT_ON_LOGIN = 3
|
||||||
|
local PLAYER_EVENT_ON_SPELL_CAST = 5
|
||||||
|
local PLAYER_EVENT_ON_LOGOUT = 4
|
||||||
|
local PLAYER_EVENT_ON_COMMAND = 42
|
||||||
|
local PLAYER_EVENT_ON_BEFORE_TELEPORT = 67
|
||||||
|
|
||||||
|
|
||||||
|
local ACCOUNT_FLAG_RECURRING_BILLING = 8192
|
||||||
|
|
||||||
|
local SUMMON_SQUIRE_SPELL_ID = 80000
|
||||||
|
local HEARTHSTONE_SPELL_ID = 8690
|
||||||
|
|
||||||
|
local SQUIRE_CREATURE_TEMPLATE_ID = 9000001
|
||||||
|
|
||||||
|
local ALLIANCE_AUCTIONEER_CREATURE_TEMPLATE_ID = 15659
|
||||||
|
local HORDE_AUCTIONEER_CREATURE_TEMPLATE_ID = 8673
|
||||||
|
|
||||||
|
local NPC_TEXT_ID = 10000000
|
||||||
|
|
||||||
|
local premiums_table = {}
|
||||||
|
local squires_table = {}
|
||||||
|
|
||||||
|
---todo extract to utils
|
||||||
|
---@param inputstr string
|
||||||
|
---@param separator string
|
||||||
|
---@return table
|
||||||
|
local function SplitString(inputstr, separator)
|
||||||
|
if separator == nil then
|
||||||
|
separator = "%s"
|
||||||
|
end
|
||||||
|
local t = {}
|
||||||
|
for str in string.gmatch(inputstr, "([^" .. separator .. "]+)") do
|
||||||
|
table.insert(t, str)
|
||||||
|
end
|
||||||
|
return t
|
||||||
|
end
|
||||||
|
|
||||||
|
---@return boolean
|
||||||
|
local function IsPremium(player)
|
||||||
|
return premiums_table[player:GetAccountId()] == true
|
||||||
|
end
|
||||||
|
|
||||||
|
local function OnPremiumPlayer(player)
|
||||||
|
player:SendAreaTriggerMessage(
|
||||||
|
'На вашем аккаунте активна подписка Premium! Благодарим за поддержку проекта MoonWell.')
|
||||||
|
|
||||||
|
player:LearnSpell(SUMMON_SQUIRE_SPELL_ID)
|
||||||
|
player:SendBroadcastMessage("Милостью Элуны вам была выдана способность |cff71d5ff|Hspell:" ..
|
||||||
|
SUMMON_SQUIRE_SPELL_ID .. "|h[" .. "Призыв Оруженосца]h|")
|
||||||
|
|
||||||
|
player:ResetSpellCooldown(HEARTHSTONE_SPELL_ID)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function RefreshPremiumStatus(player)
|
||||||
|
local query = AuthDBQuery("SELECT flags FROM account WHERE id = " .. player:GetAccountId())
|
||||||
|
|
||||||
|
if query then
|
||||||
|
local function HasFlag(mask, flag)
|
||||||
|
return bit_and(mask, flag) == flag
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
local flags = query:GetUInt32(0)
|
||||||
|
|
||||||
|
if HasFlag(flags, ACCOUNT_FLAG_RECURRING_BILLING) then
|
||||||
|
premiums_table[player:GetAccountId()] = true
|
||||||
|
|
||||||
|
OnPremiumPlayer(player)
|
||||||
|
|
||||||
|
return true
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function ApplyPremium(player)
|
||||||
|
AuthDBExecute(
|
||||||
|
string.format(
|
||||||
|
"UPDATE account SET flags = flags | %d WHERE id = %d",
|
||||||
|
ACCOUNT_FLAG_RECURRING_BILLING,
|
||||||
|
player:GetAccountId()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
RefreshPremiumStatus(player)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function OnLogin(event, player)
|
||||||
|
RefreshPremiumStatus(player)
|
||||||
|
end
|
||||||
|
|
||||||
|
--- @param event any
|
||||||
|
--- @param player any
|
||||||
|
--- @param command string
|
||||||
|
--- @param chatHandler any
|
||||||
|
local function OnCommand(event, player, command, chatHandler)
|
||||||
|
---Console is not supported yet
|
||||||
|
if not player then return end
|
||||||
|
|
||||||
|
local parts = SplitString(command:lower(), ' ')
|
||||||
|
|
||||||
|
if parts[1] ~= 'premium' then return end
|
||||||
|
|
||||||
|
if parts[2] == 'add' then
|
||||||
|
ApplyPremium(player:GetSelection())
|
||||||
|
end
|
||||||
|
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
local function OnSummonSquireCast(player)
|
||||||
|
local x, y, z, o = player:GetLocation()
|
||||||
|
|
||||||
|
if (squires_table[player:GetGUIDLow()] ~= nil) then
|
||||||
|
local existing_squire = player:GetMap():GetWorldObject(squires_table[player:GetGUIDLow()])
|
||||||
|
if (existing_squire ~= nil) then
|
||||||
|
existing_squire:DespawnOrUnsummon()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local squire = player:SpawnCreature(SQUIRE_CREATURE_TEMPLATE_ID, x + 1, y + 1, z, o, 1, 30000)
|
||||||
|
squires_table[player:GetGUIDLow()] = squire:GetGUID()
|
||||||
|
|
||||||
|
if squire then
|
||||||
|
squire:SetFaction(player:GetFaction())
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function OnSpellCast(event, player, spell, skipCheck)
|
||||||
|
local spellId = spell:GetEntry()
|
||||||
|
|
||||||
|
if spellId == SUMMON_SQUIRE_SPELL_ID then
|
||||||
|
OnSummonSquireCast(player)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function OnGossipHello(event, player, creature)
|
||||||
|
player:GossipClearMenu()
|
||||||
|
|
||||||
|
player:GossipMenuAddItem(4, "Открыть почтовый ящик", 900001, 1)
|
||||||
|
player:GossipMenuAddItem(1, "Открыть банк", 900001, 2)
|
||||||
|
player:GossipMenuAddItem(6, "Открыть аукцион", 900001, 3)
|
||||||
|
player:GossipMenuAddItem(4, "Отремонтировать снаряжение", 900001, 4)
|
||||||
|
|
||||||
|
player:GossipSendMenu(NPC_TEXT_ID, creature)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function OnGossipSelect(event, player, creature, sender, intid, code)
|
||||||
|
if sender == 900001 then
|
||||||
|
if intid == 1 then
|
||||||
|
player:SendShowMailBox(creature:GetGUID())
|
||||||
|
player:GossipComplete()
|
||||||
|
end
|
||||||
|
|
||||||
|
if intid == 2 then
|
||||||
|
player:SendShowBank(creature)
|
||||||
|
player:GossipComplete()
|
||||||
|
end
|
||||||
|
|
||||||
|
if intid == 3 then
|
||||||
|
local x, y, z, o = player:GetLocation()
|
||||||
|
local auctioneer_template = player:GetTeam() == 0 and ALLIANCE_AUCTIONEER_CREATURE_TEMPLATE_ID or
|
||||||
|
HORDE_AUCTIONEER_CREATURE_TEMPLATE_ID
|
||||||
|
local auctioneer = player:SpawnCreature(auctioneer_template, x, y, z, o, 1, 30000)
|
||||||
|
auctioneer:SetDisplayId(11686)
|
||||||
|
|
||||||
|
player:SendAuctionMenu(auctioneer)
|
||||||
|
|
||||||
|
player:GossipComplete()
|
||||||
|
end
|
||||||
|
|
||||||
|
if intid == 4 then
|
||||||
|
player:DurabilityRepairAll()
|
||||||
|
player:PlayDirectSound(1116)
|
||||||
|
player:GossipComplete()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
local function OnLogout(event, player)
|
||||||
|
player:RemoveSpell(SUMMON_SQUIRE_SPELL_ID)
|
||||||
|
squires_table[player:GetGUID()] = nil
|
||||||
|
end
|
||||||
|
|
||||||
|
local function OnBeforeTeleport(event, player, mapid, x, y, z, orientation, options, target)
|
||||||
|
if not IsPremium(player) then return end
|
||||||
|
|
||||||
|
-- Get current generic spell (SpellType 1)
|
||||||
|
local current_spell = player:GetCurrentSpell(1)
|
||||||
|
|
||||||
|
if current_spell ~= nil and current_spell:GetEntry() == HEARTHSTONE_SPELL_ID then
|
||||||
|
player:ResetSpellCooldown(HEARTHSTONE_SPELL_ID)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterCreatureGossipEvent(SQUIRE_CREATURE_TEMPLATE_ID, 1, OnGossipHello)
|
||||||
|
RegisterCreatureGossipEvent(SQUIRE_CREATURE_TEMPLATE_ID, 2, OnGossipSelect)
|
||||||
|
RegisterPlayerEvent(PLAYER_EVENT_ON_LOGIN, OnLogin)
|
||||||
|
RegisterPlayerEvent(PLAYER_EVENT_ON_LOGOUT, OnLogout)
|
||||||
|
RegisterPlayerEvent(PLAYER_EVENT_ON_COMMAND, OnCommand)
|
||||||
|
RegisterPlayerEvent(PLAYER_EVENT_ON_SPELL_CAST, OnSpellCast)
|
||||||
|
RegisterPlayerEvent(PLAYER_EVENT_ON_BEFORE_TELEPORT, OnBeforeTeleport)
|
||||||
@@ -0,0 +1,838 @@
|
|||||||
|
-- ╔════════════════════════════════════════════════════════════════════════╗
|
||||||
|
-- ║ Transmog System Eluna Script by DanielTheDeveloper ║
|
||||||
|
-- ╚════════════════════════════════════════════════════════════════════════╝
|
||||||
|
--
|
||||||
|
-- ╔══════════════════════════╗
|
||||||
|
-- ╔══════════════════════║ Transmog System Settings ║══════════════════════╗
|
||||||
|
-- ║ ╚══════════════════════════╝ ║
|
||||||
|
-- ║ Automatically adds transmog appearances to the players account-wide ║
|
||||||
|
-- ║ transmog collection when they equip an item for the first time. ║
|
||||||
|
-- ║ ║
|
||||||
|
-- ║ It is recommended to leave this option enabled. ║
|
||||||
|
-- ╟────────────────────────────────────────────────────────────────────────╢
|
||||||
|
local ADD_NEWLY_EQUIPPED_ITEMS_TO_THE_TRANSMOG_LIST = true --║
|
||||||
|
-- ╟────────────────────────────────────────────────────────────────────────╢
|
||||||
|
-- ║ ║
|
||||||
|
-- ║ Automatically adds transmog appearances to the players account-wide ║
|
||||||
|
-- ║ transmog collection when they loot an item for the first time. ║
|
||||||
|
-- ║ ║
|
||||||
|
-- ║ It is recommended to leave this option disabled as it creates the ║
|
||||||
|
-- ║ potential for a more healthy transmog economy to exist inside the ║
|
||||||
|
-- ║ auction house. ║
|
||||||
|
-- ╟────────────────────────────────────────────────────────────────────────╢
|
||||||
|
local ADD_NEWLY_LOOTED_ITEMS_TO_THE_TRANSMOG_LIST = false --║
|
||||||
|
-- ╟────────────────────────────────────────────────────────────────────────╢
|
||||||
|
-- ║ ║
|
||||||
|
-- ║ Automatically adds all applicable quest reward items as transmog ║
|
||||||
|
-- ║ appearances to the players account-wide transmog collection when ║
|
||||||
|
-- ║ completing a quest, regardless of which quest reward the player ║
|
||||||
|
-- ║ actually decided to select. ║
|
||||||
|
-- ║ ║
|
||||||
|
-- ║ It is recommended to leave this option enabled as it eliminates the ║
|
||||||
|
-- ║ dilemma of deciding between a potential transmog appearance or gear ║
|
||||||
|
-- ║ that is useful for the character. ║
|
||||||
|
-- ╟────────────────────────────────────────────────────────────────────────╢
|
||||||
|
local ADD_QUEST_REWARD_ITEMS_TO_THE_TRANSMOG_LIST = true --║
|
||||||
|
-- ╟────────────────────────────────────────────────────────────────────────╢
|
||||||
|
-- ║ ║
|
||||||
|
-- ║ Restricts armor transmog appearances to items made up of the same ║
|
||||||
|
-- ║ material. As an example, with this option enabled, cloth chest pieces ║
|
||||||
|
-- ║ can only be transmogrified to appear as other cloth chest pieces. ║
|
||||||
|
-- ║ When this option is disabled, cloth chest pieces can be ║
|
||||||
|
-- ║ transmogrified to appear as a cloth, leather, mail, or plate chest ║
|
||||||
|
-- ║ piece. ║
|
||||||
|
-- ║ ║
|
||||||
|
-- ║ It is recommended to leave this option enabled as it leaves the ║
|
||||||
|
-- ║ class fantasy intact. ║
|
||||||
|
-- ╟────────────────────────────────────────────────────────────────────────╢
|
||||||
|
local RESTRICT_ARMOR_TRANSMOG_TO_SIMILAR_MATERIALS = true --║
|
||||||
|
-- ╟────────────────────────────────────────────────────────────────────────╢
|
||||||
|
-- ║ ║
|
||||||
|
-- ║ Restricts weapon transmog appearances to the same weapon. As an ║
|
||||||
|
-- ║ example, with this option enabled, two-handed swords can only be ║
|
||||||
|
-- ║ transmogrified to appear as other two-handed swords. When this option ║
|
||||||
|
-- ║ is disabled, two-handed swords can be transmogrified to appear as a ║
|
||||||
|
-- ║ one-handed sword, staff, polearm, fishing pole, etc. ║
|
||||||
|
-- ║ ║
|
||||||
|
-- ║ It is recommended to leave this option enabled as it leaves the ║
|
||||||
|
-- ║ class fantasy intact. ║
|
||||||
|
-- ╟────────────────────────────────────────────────────────────────────────╢
|
||||||
|
local RESTRICT_WEAPON_TRANSMOG_TO_SIMILAR_WEAPONS = true --║
|
||||||
|
-- ╚════════════════════════════════════════════════════════════════════════╝
|
||||||
|
|
||||||
|
local AIO = AIO or require("AIO")
|
||||||
|
local TransmogrificationHandler = AIO.AddHandlers("TransmogrificationServer", {})
|
||||||
|
|
||||||
|
local SLOTS = 6
|
||||||
|
local CALC = 281
|
||||||
|
local PLAYER_VISIBLE_ITEM_1_ENTRYID = 283 -- Head
|
||||||
|
local PLAYER_VISIBLE_ITEM_3_ENTRYID = 287 -- Shoulder
|
||||||
|
local PLAYER_VISIBLE_ITEM_4_ENTRYID = 289 -- Shirt
|
||||||
|
local PLAYER_VISIBLE_ITEM_5_ENTRYID = 291 -- Chest
|
||||||
|
local PLAYER_VISIBLE_ITEM_6_ENTRYID = 293 -- Waist
|
||||||
|
local PLAYER_VISIBLE_ITEM_7_ENTRYID = 295 -- Legs
|
||||||
|
local PLAYER_VISIBLE_ITEM_8_ENTRYID = 297 -- Feet
|
||||||
|
local PLAYER_VISIBLE_ITEM_9_ENTRYID = 299 -- Wrist
|
||||||
|
local PLAYER_VISIBLE_ITEM_10_ENTRYID = 301 -- Hands
|
||||||
|
local PLAYER_VISIBLE_ITEM_15_ENTRYID = 311 -- Back
|
||||||
|
local PLAYER_VISIBLE_ITEM_16_ENTRYID = 313 -- Main
|
||||||
|
local PLAYER_VISIBLE_ITEM_17_ENTRYID = 315 -- Off
|
||||||
|
local PLAYER_VISIBLE_ITEM_18_ENTRYID = 317 -- Ranged
|
||||||
|
local PLAYER_VISIBLE_ITEM_19_ENTRYID = 319 -- Tabard
|
||||||
|
local UNUSABLE_INVENTORY_TYPES = {[2] = true, [11] = true, [12] = true, [18] = true, [24] = true, [27] = true, [28] = true}
|
||||||
|
|
||||||
|
-- TODO: Add further language support.
|
||||||
|
local localeMessages = {
|
||||||
|
LOOT_ITEM_LOCALE = {
|
||||||
|
[0] = " has been added to your appearance collection.", -- enUS/enGB
|
||||||
|
[3] = " wurde deiner Transmog-Sammlung hinzugefügt.", -- deDE
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
local function GetLocalizedMessage(messageID, locale, ...)
|
||||||
|
local message = localeMessages[messageID][locale] or localeMessages[messageID][0]
|
||||||
|
if select("#", ...) > 0 then
|
||||||
|
return string.format(message, ...)
|
||||||
|
end
|
||||||
|
return message
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_CalculateSlot(slot)
|
||||||
|
if (slot == 0) then
|
||||||
|
slot = 1
|
||||||
|
elseif (slot >= 2) then
|
||||||
|
slot = slot + 1
|
||||||
|
end
|
||||||
|
return CALC + (slot * 2);
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_CalculateSlotReverse(slot)
|
||||||
|
local reverseSlot = (slot - CALC) / 2
|
||||||
|
if (reverseSlot == 1) then
|
||||||
|
return 0;
|
||||||
|
end
|
||||||
|
return reverseSlot;
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_OnCharacterCreate(event, player)
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_1_ENTRYID .. "', NULL, '');") -- Head
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_3_ENTRYID .. "', NULL, '');") -- Shoulder
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_4_ENTRYID .. "', NULL, '');") -- Shirt
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_5_ENTRYID .. "', NULL, '');") -- Chest
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_6_ENTRYID .. "', NULL, '');") -- Waist
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_7_ENTRYID .. "', NULL, '');") -- Legs
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_8_ENTRYID .. "', NULL, '');") -- Feet
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_9_ENTRYID .. "', NULL, '');") -- Wrist
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_10_ENTRYID .. "', NULL, '');") -- Hands
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_15_ENTRYID .. "', NULL, '');") -- Back
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_16_ENTRYID .. "', NULL, '');") -- Main
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_17_ENTRYID .. "', NULL, '');") -- Off
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_18_ENTRYID .. "', NULL, '');") -- Ranged
|
||||||
|
CharDBQuery("INSERT IGNORE INTO `character_transmog` (`player_guid`, `slot`, `item`, `real_item`) VALUES (" .. playerGUID .. ", '" .. PLAYER_VISIBLE_ITEM_19_ENTRYID .. "', NULL, '');") -- Tabard
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_OnCharacterDelete(event, guid)
|
||||||
|
CharDBQuery("DELETE FROM character_transmog WHERE player_guid = " .. guid .. "")
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_OnCharacterLogin(event, player)
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
local playerExistsQuery = CharDBQuery("SELECT COUNT(*) FROM character_transmog WHERE player_guid = " .. playerGUID .. ";")
|
||||||
|
local playerExists = playerExistsQuery:GetUInt32(0) > 0
|
||||||
|
if not playerExists then
|
||||||
|
Transmog_OnCharacterCreate(event, player)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.LootItemLocale(player, item, count, locale)
|
||||||
|
local accountGUID = player:GetAccountId()
|
||||||
|
local itemID
|
||||||
|
local itemTemplate
|
||||||
|
|
||||||
|
if type(item) == "number" then
|
||||||
|
itemID = item
|
||||||
|
itemTemplate = GetItemTemplate(itemID)
|
||||||
|
else
|
||||||
|
itemTemplate = item:GetItemTemplate()
|
||||||
|
itemID = itemTemplate:GetItemId()
|
||||||
|
end
|
||||||
|
|
||||||
|
local inventoryType = itemTemplate:GetInventoryType()
|
||||||
|
local inventorySubType = itemTemplate:GetSubClass()
|
||||||
|
local class = itemTemplate:GetClass()
|
||||||
|
|
||||||
|
if (class == 2 or class == 4) and not UNUSABLE_INVENTORY_TYPES[inventoryType] then
|
||||||
|
local countQuery = AuthDBQuery("SELECT COUNT(*) FROM account_transmog WHERE account_id = " .. accountGUID .. " AND unlocked_item_id = " .. itemID .. ";")
|
||||||
|
local count = countQuery:GetUInt32(0)
|
||||||
|
local isNewTransmog = (count == 0)
|
||||||
|
|
||||||
|
local displayID = itemTemplate:GetDisplayId()
|
||||||
|
|
||||||
|
local displayExistsQuery = AuthDBQuery("SELECT COUNT(*) FROM account_transmog WHERE account_id = " .. accountGUID .. " AND display_id = " .. displayID .. ";")
|
||||||
|
local displayExists = displayExistsQuery:GetUInt32(0) > 0
|
||||||
|
|
||||||
|
if isNewTransmog and not displayExists then
|
||||||
|
local itemName = itemTemplate:GetName()
|
||||||
|
local locItemName = itemTemplate:GetName(locale)
|
||||||
|
local itemQuality = itemTemplate:GetQuality()
|
||||||
|
|
||||||
|
itemName = itemName:gsub("'", "''")
|
||||||
|
AuthDBQuery("INSERT IGNORE INTO `account_transmog` (`account_id`, `unlocked_item_id`, `inventory_type`, `inventory_subtype`,`display_id`, `item_name`) VALUES (" .. accountGUID .. ", " .. itemID .. ", " .. inventoryType .. ", " .. inventorySubType .. ", " .. displayID .. ", '" .. itemName .. "');")
|
||||||
|
|
||||||
|
if locItemName == nil then
|
||||||
|
locItemName = itemTemplate:GetName(0)
|
||||||
|
end
|
||||||
|
|
||||||
|
local qualityColors = {
|
||||||
|
[0] = "|cff9d9d9d", -- Poor
|
||||||
|
[1] = "|cffffffff", -- Common
|
||||||
|
[2] = "|cff1eff00", -- Uncommon
|
||||||
|
[3] = "|cff0070dd", -- Rare
|
||||||
|
[4] = "|cffa335ee", -- Epic
|
||||||
|
[5] = "|cffff8000", -- Legendary
|
||||||
|
[6] = "|cffe6cc80" -- Heirloom
|
||||||
|
}
|
||||||
|
|
||||||
|
local colorCode = qualityColors[itemQuality] or "|cffffffff" -- Default to white if quality not found
|
||||||
|
|
||||||
|
local itemLink = "|Hitem:" .. itemID .. ":0:0:0:0:0:0:0:0|h" .. colorCode .. "[" .. locItemName .. "]|r|h|r"
|
||||||
|
player:SendBroadcastMessage(itemLink .. GetLocalizedMessage("LOOT_ITEM_LOCALE", locale))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function GetDisplaySlotForEquipmentSlot(equipSlot)
|
||||||
|
local slotMapping = {
|
||||||
|
[0] = PLAYER_VISIBLE_ITEM_1_ENTRYID, -- Head
|
||||||
|
[2] = PLAYER_VISIBLE_ITEM_3_ENTRYID, -- Shoulders
|
||||||
|
[3] = PLAYER_VISIBLE_ITEM_4_ENTRYID, -- Shirt
|
||||||
|
[4] = PLAYER_VISIBLE_ITEM_5_ENTRYID, -- Chest
|
||||||
|
[5] = PLAYER_VISIBLE_ITEM_6_ENTRYID, -- Waist
|
||||||
|
[6] = PLAYER_VISIBLE_ITEM_7_ENTRYID, -- Legs
|
||||||
|
[7] = PLAYER_VISIBLE_ITEM_8_ENTRYID, -- Feet
|
||||||
|
[8] = PLAYER_VISIBLE_ITEM_9_ENTRYID, -- Wrists
|
||||||
|
[9] = PLAYER_VISIBLE_ITEM_10_ENTRYID, -- Hands
|
||||||
|
[14] = PLAYER_VISIBLE_ITEM_15_ENTRYID, -- Back
|
||||||
|
[15] = PLAYER_VISIBLE_ITEM_16_ENTRYID, -- Main Hand
|
||||||
|
[16] = PLAYER_VISIBLE_ITEM_17_ENTRYID, -- Off Hand
|
||||||
|
[17] = PLAYER_VISIBLE_ITEM_18_ENTRYID, -- Ranged
|
||||||
|
[18] = PLAYER_VISIBLE_ITEM_19_ENTRYID -- Tabard
|
||||||
|
}
|
||||||
|
|
||||||
|
return slotMapping[equipSlot]
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_OnEquipItem(event, player, item, bag, slot)
|
||||||
|
local itemID = item:GetItemTemplate():GetItemId()
|
||||||
|
local locale = player:GetDbLocaleIndex()
|
||||||
|
|
||||||
|
if ADD_NEWLY_EQUIPPED_ITEMS_TO_THE_TRANSMOG_LIST then
|
||||||
|
TransmogrificationHandler.LootItemLocale(player, itemID, 1, locale)
|
||||||
|
end
|
||||||
|
|
||||||
|
local class = item:GetItemTemplate():GetClass()
|
||||||
|
local inventoryType = item:GetItemTemplate():GetInventoryType()
|
||||||
|
|
||||||
|
if (class == 2 or class == 4) and not UNUSABLE_INVENTORY_TYPES[inventoryType] then
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
local displaySlot = GetDisplaySlotForEquipmentSlot(slot)
|
||||||
|
|
||||||
|
if displaySlot then
|
||||||
|
CharDBQuery("UPDATE character_transmog SET real_item = " .. itemID .. " WHERE player_guid = " .. playerGUID .. " AND slot = " .. displaySlot)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_OnLootItem(event, player, item, count)
|
||||||
|
local locale = player:GetDbLocaleIndex()
|
||||||
|
TransmogrificationHandler.LootItemLocale(player, item, 1, locale)
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_OnQuestComplete(event, player, quest)
|
||||||
|
local questID = quest:GetId()
|
||||||
|
local locale = player:GetDbLocaleIndex()
|
||||||
|
|
||||||
|
local questRewardsQuery = WorldDBQuery("SELECT RewardItem1, RewardItem2, RewardItem3, RewardItem4, RewardChoiceItemID1, RewardChoiceItemID2, RewardChoiceItemID3, RewardChoiceItemID4, RewardChoiceItemID5, RewardChoiceItemID6 FROM quest_template WHERE ID = " .. questID .. ";")
|
||||||
|
|
||||||
|
if not questRewardsQuery then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = 0, 9 do
|
||||||
|
local itemID = questRewardsQuery:GetUInt32(i)
|
||||||
|
if itemID and itemID > 0 then
|
||||||
|
TransmogrificationHandler.LootItemLocale(player, itemID, 1, locale)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function GetEquipmentSlot(displaySlot)
|
||||||
|
-- Map from display/visual slots to actual equipment slots
|
||||||
|
local slotMapping = {
|
||||||
|
[PLAYER_VISIBLE_ITEM_1_ENTRYID] = 0, -- Head (0 in equipment slot system)
|
||||||
|
[PLAYER_VISIBLE_ITEM_3_ENTRYID] = 2, -- Shoulders
|
||||||
|
[PLAYER_VISIBLE_ITEM_4_ENTRYID] = 3, -- Shirt
|
||||||
|
[PLAYER_VISIBLE_ITEM_5_ENTRYID] = 4, -- Chest
|
||||||
|
[PLAYER_VISIBLE_ITEM_6_ENTRYID] = 5, -- Waist
|
||||||
|
[PLAYER_VISIBLE_ITEM_7_ENTRYID] = 6, -- Legs
|
||||||
|
[PLAYER_VISIBLE_ITEM_8_ENTRYID] = 7, -- Feet
|
||||||
|
[PLAYER_VISIBLE_ITEM_9_ENTRYID] = 8, -- Wrists
|
||||||
|
[PLAYER_VISIBLE_ITEM_10_ENTRYID] = 9, -- Hands
|
||||||
|
[PLAYER_VISIBLE_ITEM_15_ENTRYID] = 14, -- Back/Cloak
|
||||||
|
[PLAYER_VISIBLE_ITEM_16_ENTRYID] = 15, -- Main Hand
|
||||||
|
[PLAYER_VISIBLE_ITEM_17_ENTRYID] = 16, -- Off Hand
|
||||||
|
[PLAYER_VISIBLE_ITEM_18_ENTRYID] = 17, -- Ranged
|
||||||
|
[PLAYER_VISIBLE_ITEM_19_ENTRYID] = 18, -- Tabard
|
||||||
|
}
|
||||||
|
|
||||||
|
return slotMapping[displaySlot] or Transmog_CalculateSlotReverse(displaySlot)
|
||||||
|
end
|
||||||
|
|
||||||
|
-- TODO: add lua/c++ function for unequip!!
|
||||||
|
function TransmogrificationHandler.OnUnequipItem(player)
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
|
||||||
|
-- Check all slots to see if any items were unequipped
|
||||||
|
local slots = {
|
||||||
|
PLAYER_VISIBLE_ITEM_1_ENTRYID, -- Head
|
||||||
|
PLAYER_VISIBLE_ITEM_3_ENTRYID, -- Shoulder
|
||||||
|
PLAYER_VISIBLE_ITEM_4_ENTRYID, -- Shirt
|
||||||
|
PLAYER_VISIBLE_ITEM_5_ENTRYID, -- Chest
|
||||||
|
PLAYER_VISIBLE_ITEM_6_ENTRYID, -- Waist
|
||||||
|
PLAYER_VISIBLE_ITEM_7_ENTRYID, -- Legs
|
||||||
|
PLAYER_VISIBLE_ITEM_8_ENTRYID, -- Feet
|
||||||
|
PLAYER_VISIBLE_ITEM_9_ENTRYID, -- Wrist
|
||||||
|
PLAYER_VISIBLE_ITEM_10_ENTRYID, -- Hands
|
||||||
|
PLAYER_VISIBLE_ITEM_15_ENTRYID, -- Back
|
||||||
|
PLAYER_VISIBLE_ITEM_16_ENTRYID, -- Main
|
||||||
|
PLAYER_VISIBLE_ITEM_17_ENTRYID, -- Off
|
||||||
|
PLAYER_VISIBLE_ITEM_18_ENTRYID, -- Ranged
|
||||||
|
PLAYER_VISIBLE_ITEM_19_ENTRYID -- Tabard
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, slot in ipairs(slots) do
|
||||||
|
-- Get the corresponding equipment slot
|
||||||
|
local equipmentSlot = GetEquipmentSlot(slot)
|
||||||
|
|
||||||
|
-- Check if this slot has an item equipped
|
||||||
|
local currentItem = player:GetEquippedItemBySlot(equipmentSlot)
|
||||||
|
|
||||||
|
-- If the slot is empty but we have a transmog value, we need to clear it
|
||||||
|
if not currentItem then
|
||||||
|
local transmogQuery = CharDBQuery("SELECT item FROM character_transmog WHERE player_guid = "..playerGUID.." AND slot = "..slot..";")
|
||||||
|
|
||||||
|
if transmogQuery and transmogQuery:GetUInt32(0) and transmogQuery:GetUInt32(0) > 0 then
|
||||||
|
-- Clear the transmog by setting both item and real_item to NULL
|
||||||
|
CharDBQuery("UPDATE character_transmog SET item = NULL, real_item = NULL WHERE player_guid = "..playerGUID.." AND slot = "..slot..";")
|
||||||
|
player:SetUInt32Value(tonumber(slot), 0)
|
||||||
|
|
||||||
|
-- Inform the client to update the display for this slot
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "ClearSlotTransmogrification", slot)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_Load(player)
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
|
||||||
|
local transmogs = CharDBQuery("SELECT item, slot FROM character_transmog WHERE player_guid = "..playerGUID..";")
|
||||||
|
if (transmogs == nil) then
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = 1, transmogs:GetRowCount(), 1 do
|
||||||
|
local currentRow = transmogs:GetRow()
|
||||||
|
local slot = currentRow["slot"]
|
||||||
|
local item = currentRow["item"]
|
||||||
|
if (item ~= nil and item ~= '') then
|
||||||
|
player:SetUInt32Value(tonumber(slot), item)
|
||||||
|
end
|
||||||
|
transmogs:NextRow()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function Transmog_OnLogin(event, player)
|
||||||
|
-- Apply transmog on login
|
||||||
|
-- Transmog_Load(player)
|
||||||
|
--local item = player:GetEquippedItemBySlot(4)
|
||||||
|
--print(item:GetName())
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.LoadPlayer(player)
|
||||||
|
Transmog_Load(player)
|
||||||
|
player:SetUInt32Value(147, 1) -- use unit padding
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.EquipTransmogItem(player, item, slot)
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
|
||||||
|
if item == nil and item ~= 0 then
|
||||||
|
local oldItem = CharDBQuery("SELECT real_item FROM character_transmog WHERE player_guid = "..playerGUID.." AND slot = "..slot..";")
|
||||||
|
local oldItemID = oldItem:GetUInt32(0)
|
||||||
|
if oldItemID == nil or oldItemID == 0 then
|
||||||
|
CharDBQuery("INSERT INTO character_transmog (`player_guid`, `slot`, `item`) VALUES ("..playerGUID..", '"..slot.."', NULL) ON DUPLICATE KEY UPDATE item = VALUES(item);")
|
||||||
|
player:SetUInt32Value(tonumber(slot), 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
CharDBQuery("INSERT INTO character_transmog (`player_guid`, `slot`, `item`, `real_item`) VALUES ("..playerGUID..", '"..slot.."', NULL, "..oldItemID..") ON DUPLICATE KEY UPDATE item = VALUES(item), real_item = VALUES(real_item);")
|
||||||
|
player:SetUInt32Value(tonumber(slot), oldItemID)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local oldItem = CharDBQuery("SELECT real_item FROM character_transmog WHERE player_guid = "..playerGUID.." AND slot = "..slot..";")
|
||||||
|
local oldItemID = oldItem:GetUInt32(0)
|
||||||
|
if oldItemID == nil or oldItemID == 0 then
|
||||||
|
CharDBQuery("INSERT INTO character_transmog (`player_guid`, `slot`, `item`) VALUES ("..playerGUID..", '"..slot.."', "..item..") ON DUPLICATE KEY UPDATE item = VALUES(item);")
|
||||||
|
else
|
||||||
|
CharDBQuery("INSERT INTO character_transmog (`player_guid`, `slot`, `item`, `real_item`) VALUES ("..playerGUID..", '"..slot.."', "..item..", "..oldItemID..") ON DUPLICATE KEY UPDATE item = VALUES(item), real_item = VALUES(real_item);")
|
||||||
|
end
|
||||||
|
player:SetUInt32Value(tonumber(slot), item)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.EquipAllTransmogItems(player, transmogPreview)
|
||||||
|
if (transmogPreview == {}) then
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
|
||||||
|
for slot, item in ipairs(transmogPreview) do
|
||||||
|
player:SetUInt32Value(tonumber(slot), item)
|
||||||
|
CharDBQuery("INSERT INTO character_transmog (`player_guid`, `slot`, `item`) VALUES ("..playerGUID..", '"..slot.."', "..item..") ON DUPLICATE KEY UPDATE item = VALUES(item);")
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.UnequipTransmogItem(player, slot)
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
|
||||||
|
-- Get the corresponding equipment slot using our mapping function
|
||||||
|
local equipmentSlot = GetEquipmentSlot(slot)
|
||||||
|
|
||||||
|
-- Check if there's an actual item equipped in this slot
|
||||||
|
local currentItem = player:GetEquippedItemBySlot(equipmentSlot)
|
||||||
|
|
||||||
|
-- If no item is currently equipped, we should set the visual to 0
|
||||||
|
if not currentItem then
|
||||||
|
CharDBQuery("UPDATE character_transmog SET item = NULL, real_item = NULL WHERE player_guid = "..playerGUID.." AND slot = "..slot..";")
|
||||||
|
player:SetUInt32Value(tonumber(slot), 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Otherwise, handle normally but ensure we're setting to the real item or 0
|
||||||
|
local oldItem = CharDBQuery("SELECT real_item FROM character_transmog WHERE player_guid = "..playerGUID.." AND slot = "..slot..";")
|
||||||
|
|
||||||
|
-- If no record or null real_item, set to the actual item's appearance
|
||||||
|
if not oldItem or not oldItem:GetUInt32(0) or oldItem:GetUInt32(0) == 0 then
|
||||||
|
-- Here we set item to NULL (not 0) to indicate no transmog is applied
|
||||||
|
CharDBQuery("UPDATE character_transmog SET item = NULL WHERE player_guid = "..playerGUID.." AND slot = "..slot..";")
|
||||||
|
|
||||||
|
-- Get the current equipped item's ID to display
|
||||||
|
local realItemID = currentItem:GetItemTemplate():GetItemId()
|
||||||
|
player:SetUInt32Value(tonumber(slot), realItemID)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- If we have a real_item value, restore that appearance
|
||||||
|
local oldItemID = oldItem:GetUInt32(0)
|
||||||
|
CharDBQuery("UPDATE character_transmog SET item = NULL WHERE player_guid = "..playerGUID.." AND slot = "..slot..";")
|
||||||
|
player:SetUInt32Value(tonumber(slot), oldItemID)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.displayTransmog(player, spellid)
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "TransmogrificationFrame")
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.Print(player, ...)
|
||||||
|
print(...)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.SetTransmogItemIDs(player)
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
|
||||||
|
local transmogs = CharDBQuery('SELECT item, real_item, slot FROM character_transmog WHERE player_guid = '..playerGUID..';') -- AND slot NOT IN ("313", "315", "317")
|
||||||
|
if (transmogs == nil) then
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
for i = 1, transmogs:GetRowCount(), 1 do
|
||||||
|
local currentRow = transmogs:GetRow()
|
||||||
|
local item = currentRow["item"]
|
||||||
|
local slot = currentRow["slot"]
|
||||||
|
local real_item = currentRow["real_item"]
|
||||||
|
local validSlotItem = player:GetUInt32Value(tonumber(slot))
|
||||||
|
if (validSlotItem == 0) then
|
||||||
|
CharDBQuery("INSERT INTO character_transmog (`player_guid`, `slot`, `item`, `real_item`) VALUES ("..playerGUID..", '"..slot.."', 0, "..real_item..") ON DUPLICATE KEY UPDATE item = VALUES(item), real_item = VALUES(real_item);")
|
||||||
|
end
|
||||||
|
if (not item or item == 0 and real_item ~= nil and real_item ~= 0 and (validSlotItem ~= 0 or not validSlotItem)) then
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "SetTransmogItemIDClient", slot, 0, real_item)
|
||||||
|
else
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "SetTransmogItemIDClient", slot, item, real_item)
|
||||||
|
end
|
||||||
|
transmogs:NextRow()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.SetCurrentSlotItemIDs(player, slot, page)
|
||||||
|
local accountGUID = player:GetAccountId()
|
||||||
|
|
||||||
|
-- Define inventory type mapping
|
||||||
|
local inventoryTypesMapping = {
|
||||||
|
[PLAYER_VISIBLE_ITEM_1_ENTRYID] = "= 1",
|
||||||
|
[PLAYER_VISIBLE_ITEM_3_ENTRYID] = "= 3",
|
||||||
|
[PLAYER_VISIBLE_ITEM_4_ENTRYID] = "= 4",
|
||||||
|
[PLAYER_VISIBLE_ITEM_5_ENTRYID] = "IN (5, 20)",
|
||||||
|
[PLAYER_VISIBLE_ITEM_6_ENTRYID] = "= 6",
|
||||||
|
[PLAYER_VISIBLE_ITEM_7_ENTRYID] = "= 7",
|
||||||
|
[PLAYER_VISIBLE_ITEM_8_ENTRYID] = "= 8",
|
||||||
|
[PLAYER_VISIBLE_ITEM_9_ENTRYID] = "= 9",
|
||||||
|
[PLAYER_VISIBLE_ITEM_10_ENTRYID] = "= 10",
|
||||||
|
[PLAYER_VISIBLE_ITEM_15_ENTRYID] = "= 16",
|
||||||
|
[PLAYER_VISIBLE_ITEM_16_ENTRYID] = "IN (13, 17, 21)",
|
||||||
|
[PLAYER_VISIBLE_ITEM_17_ENTRYID] = "IN (13, 17, 22, 23, 14)",
|
||||||
|
[PLAYER_VISIBLE_ITEM_18_ENTRYID] = "IN (15, 25, 26)",
|
||||||
|
[PLAYER_VISIBLE_ITEM_19_ENTRYID] = "= 19"
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Get the inventory type for the given slot
|
||||||
|
local inventoryTypes = inventoryTypesMapping[slot]
|
||||||
|
if not inventoryTypes then
|
||||||
|
return -- Slot not valid, exit early
|
||||||
|
end
|
||||||
|
|
||||||
|
local equipmentSlot = nil
|
||||||
|
|
||||||
|
if slot == PLAYER_VISIBLE_ITEM_3_ENTRYID then
|
||||||
|
equipmentSlot = 2 -- Shoulder
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_4_ENTRYID then
|
||||||
|
equipmentSlot = 3 -- Shirt
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_5_ENTRYID then
|
||||||
|
equipmentSlot = 4 -- Chest
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_6_ENTRYID then
|
||||||
|
equipmentSlot = 5 -- Waist
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_10_ENTRYID then
|
||||||
|
equipmentSlot = 9 -- Hands
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_15_ENTRYID then
|
||||||
|
equipmentSlot = 14 -- Back
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_16_ENTRYID then
|
||||||
|
equipmentSlot = 15 -- Main
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_17_ENTRYID then
|
||||||
|
equipmentSlot = 16 -- Off
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_18_ENTRYID then
|
||||||
|
equipmentSlot = 17 -- Ranged
|
||||||
|
else
|
||||||
|
equipmentSlot = Transmog_CalculateSlotReverse(slot)
|
||||||
|
end
|
||||||
|
|
||||||
|
local currentItem = player:GetEquippedItemBySlot(equipmentSlot)
|
||||||
|
local equippedItemType = nil
|
||||||
|
local equippedItemSubType = nil
|
||||||
|
|
||||||
|
if currentItem then
|
||||||
|
equippedItemType = currentItem:GetClass()
|
||||||
|
equippedItemSubType = currentItem:GetSubClass()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Calculate page offset for pagination
|
||||||
|
local pageOffset = (page > 1) and (SLOTS * (page - 1)) or 0
|
||||||
|
|
||||||
|
local queryConditions = "account_id = " .. accountGUID .. " AND inventory_type " .. inventoryTypes
|
||||||
|
|
||||||
|
if equippedItemSubType then
|
||||||
|
if (RESTRICT_ARMOR_TRANSMOG_TO_SIMILAR_MATERIALS and equippedItemType == 4) then
|
||||||
|
if slot == PLAYER_VISIBLE_ITEM_5_ENTRYID then -- Special check for chestpiece/robes.
|
||||||
|
queryConditions = queryConditions .. " AND inventory_subtype = " .. equippedItemSubType
|
||||||
|
else
|
||||||
|
queryConditions = queryConditions .. " AND inventory_subtype = " .. equippedItemSubType
|
||||||
|
end
|
||||||
|
elseif (RESTRICT_WEAPON_TRANSMOG_TO_SIMILAR_WEAPONS and equippedItemType == 2) then
|
||||||
|
queryConditions = queryConditions .. " AND inventory_subtype = " .. equippedItemSubType
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Query to count matching transmogs
|
||||||
|
local countQuery = string.format(
|
||||||
|
"SELECT COUNT(unlocked_item_id) FROM account_transmog WHERE %s",
|
||||||
|
queryConditions
|
||||||
|
)
|
||||||
|
local countResult = AuthDBQuery(countQuery)
|
||||||
|
if not countResult then
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "InitTab", {}, page, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Get the total number of transmogs
|
||||||
|
local totalTransmogs = countResult:GetUInt32(0)
|
||||||
|
local hasMorePages = (totalTransmogs > SLOTS * page)
|
||||||
|
|
||||||
|
-- Query to retrieve transmogs for the current page
|
||||||
|
local transmogQuery = string.format(
|
||||||
|
"SELECT unlocked_item_id FROM account_transmog WHERE %s LIMIT %d OFFSET %d;",
|
||||||
|
queryConditions, SLOTS, pageOffset
|
||||||
|
)
|
||||||
|
local transmogs = AuthDBQuery(transmogQuery)
|
||||||
|
if not transmogs then
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "InitTab", {}, page, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Collect the unlocked item IDs
|
||||||
|
local currentSlotItemIDs = {}
|
||||||
|
for i = 1, transmogs:GetRowCount() do
|
||||||
|
local currentRow = transmogs:GetRow()
|
||||||
|
local item = currentRow["unlocked_item_id"]
|
||||||
|
table.insert(currentSlotItemIDs, item)
|
||||||
|
transmogs:NextRow()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Return the result to the player
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "InitTab", currentSlotItemIDs, page, hasMorePages)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.SetSearchCurrentSlotItemIDs(player, slot, page, search)
|
||||||
|
-- Ensure search is not empty or nil
|
||||||
|
if ( search == nil or serach == '' ) then
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Escape special characters in search string
|
||||||
|
search = search:gsub("[%'`&\"]", "%%")
|
||||||
|
|
||||||
|
-- Define slot-to-inventory type mapping
|
||||||
|
local inventoryTypesMapping = {
|
||||||
|
[PLAYER_VISIBLE_ITEM_1_ENTRYID] = "= 1",
|
||||||
|
[PLAYER_VISIBLE_ITEM_3_ENTRYID] = "= 3",
|
||||||
|
[PLAYER_VISIBLE_ITEM_4_ENTRYID] = "= 4",
|
||||||
|
[PLAYER_VISIBLE_ITEM_5_ENTRYID] = "IN (5, 20)",
|
||||||
|
[PLAYER_VISIBLE_ITEM_6_ENTRYID] = "= 6",
|
||||||
|
[PLAYER_VISIBLE_ITEM_7_ENTRYID] = "= 7",
|
||||||
|
[PLAYER_VISIBLE_ITEM_8_ENTRYID] = "= 8",
|
||||||
|
[PLAYER_VISIBLE_ITEM_9_ENTRYID] = "= 9",
|
||||||
|
[PLAYER_VISIBLE_ITEM_10_ENTRYID] = "= 10",
|
||||||
|
[PLAYER_VISIBLE_ITEM_15_ENTRYID] = "= 16",
|
||||||
|
[PLAYER_VISIBLE_ITEM_16_ENTRYID] = "IN (13, 17, 21)",
|
||||||
|
[PLAYER_VISIBLE_ITEM_17_ENTRYID] = "IN (13, 17, 22, 23, 14)",
|
||||||
|
[PLAYER_VISIBLE_ITEM_18_ENTRYID] = "IN (15, 25, 26)",
|
||||||
|
[PLAYER_VISIBLE_ITEM_19_ENTRYID] = "= 19"
|
||||||
|
}
|
||||||
|
|
||||||
|
-- Get inventory type for the given slot
|
||||||
|
local inventoryTypes = inventoryTypesMapping[slot]
|
||||||
|
if not inventoryTypes then
|
||||||
|
return -- Slot not valid
|
||||||
|
end
|
||||||
|
|
||||||
|
local equipmentSlot = nil
|
||||||
|
|
||||||
|
if slot == PLAYER_VISIBLE_ITEM_3_ENTRYID then
|
||||||
|
equipmentSlot = 2 -- Shoulder
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_4_ENTRYID then
|
||||||
|
equipmentSlot = 3 -- Shirt
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_5_ENTRYID then
|
||||||
|
equipmentSlot = 4 -- Chest
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_6_ENTRYID then
|
||||||
|
equipmentSlot = 5 -- Waist
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_10_ENTRYID then
|
||||||
|
equipmentSlot = 9 -- Hands
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_15_ENTRYID then
|
||||||
|
equipmentSlot = 14 -- Back
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_16_ENTRYID then
|
||||||
|
equipmentSlot = 15 -- Main
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_17_ENTRYID then
|
||||||
|
equipmentSlot = 16 -- Off
|
||||||
|
elseif slot == PLAYER_VISIBLE_ITEM_18_ENTRYID then
|
||||||
|
equipmentSlot = 17 -- Ranged
|
||||||
|
else
|
||||||
|
equipmentSlot = Transmog_CalculateSlotReverse(slot)
|
||||||
|
end
|
||||||
|
|
||||||
|
local currentItem = player:GetEquippedItemBySlot(equipmentSlot)
|
||||||
|
local equippedItemType = nil
|
||||||
|
local equippedItemSubType = nil
|
||||||
|
|
||||||
|
if currentItem then
|
||||||
|
equippedItemType = currentItem:GetClass()
|
||||||
|
equippedItemSubType = currentItem:GetSubClass()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Calculate page offset
|
||||||
|
local pageOffset = (page > 1) and (SLOTS * (page - 1)) or 0
|
||||||
|
|
||||||
|
local queryConditions = "account_id = " .. player:GetAccountId() .. " AND inventory_type " .. inventoryTypes .. " AND (display_id LIKE '%" .. search .. "%' OR item_name LIKE '%" .. search .. "%')"
|
||||||
|
|
||||||
|
if equippedItemSubType then
|
||||||
|
if (RESTRICT_ARMOR_TRANSMOG_TO_SIMILAR_MATERIALS and equippedItemType == 4) then
|
||||||
|
if slot == PLAYER_VISIBLE_ITEM_5_ENTRYID then -- Special check for chestpiece/robes.
|
||||||
|
queryConditions = queryConditions .. " AND inventory_subtype = " .. equippedItemSubType
|
||||||
|
else
|
||||||
|
queryConditions = queryConditions .. " AND inventory_subtype = " .. equippedItemSubType
|
||||||
|
end
|
||||||
|
elseif (RESTRICT_WEAPON_TRANSMOG_TO_SIMILAR_WEAPONS and equippedItemType == 2) then
|
||||||
|
queryConditions = queryConditions .. " AND inventory_subtype = " .. equippedItemSubType
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Query to count matching transmogs
|
||||||
|
local countQuery = string.format(
|
||||||
|
"SELECT COUNT(unlocked_item_id) FROM account_transmog WHERE %s;",
|
||||||
|
queryConditions
|
||||||
|
)
|
||||||
|
local countResult = AuthDBQuery(countQuery)
|
||||||
|
if not countResult then
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "InitTab", {}, page, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local totalTransmogs = countResult:GetUInt32(0)
|
||||||
|
local hasMorePages = (totalTransmogs > SLOTS * page)
|
||||||
|
|
||||||
|
-- Query to get transmogs
|
||||||
|
local transmogQuery = string.format(
|
||||||
|
"SELECT unlocked_item_id FROM account_transmog WHERE %s LIMIT %d OFFSET %d;",
|
||||||
|
queryConditions, SLOTS, pageOffset
|
||||||
|
)
|
||||||
|
local transmogs = AuthDBQuery(transmogQuery)
|
||||||
|
if not transmogs then
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "InitTab", {}, page, false)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Collect the unlocked item IDs
|
||||||
|
local currentSlotItemIDs = {}
|
||||||
|
for i = 1, transmogs:GetRowCount() do
|
||||||
|
local currentRow = transmogs:GetRow()
|
||||||
|
local item = currentRow["unlocked_item_id"]
|
||||||
|
table.insert(currentSlotItemIDs, item)
|
||||||
|
transmogs:NextRow()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Return the result
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "InitTab", currentSlotItemIDs, page, hasMorePages)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.SetEquipmentTransmogInfo(player, slot, currentSlotTooltip)
|
||||||
|
local playerGUID = player:GetGUIDLow()
|
||||||
|
|
||||||
|
local transmog = CharDBQuery("SELECT COUNT(item) FROM character_transmog WHERE player_guid = "..playerGUID.." AND slot = '"..slot.."';")
|
||||||
|
if (transmog == nil) then
|
||||||
|
return;
|
||||||
|
end
|
||||||
|
|
||||||
|
if (transmog:GetUInt32(0) ~= 0) then
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "SetEquipmentTransmogInfoClient", currentSlotTooltip)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.GetItemsWithSameAppearance(player, itemID)
|
||||||
|
-- Query the server for the display ID of the new item
|
||||||
|
local displayIDQuery = "SELECT displayid FROM item_template WHERE entry = " .. itemID .. ";"
|
||||||
|
local displayIDResult = WorldDBQuery(displayIDQuery)
|
||||||
|
|
||||||
|
if not displayIDResult then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local displayID = displayIDResult:GetUInt32(0)
|
||||||
|
|
||||||
|
-- Find all items with the same display ID
|
||||||
|
local matchingItemsQuery = "SELECT entry FROM item_template WHERE displayid = " .. displayID .. ";"
|
||||||
|
local matchingItemsResult = WorldDBQuery(matchingItemsQuery)
|
||||||
|
|
||||||
|
if not matchingItemsResult then
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Collect the matching item IDs into a table
|
||||||
|
local matchingItems = {}
|
||||||
|
for i = 1, matchingItemsResult:GetRowCount() do
|
||||||
|
local currentRow = matchingItemsResult:GetRow()
|
||||||
|
local matchingItemID = currentRow["entry"]
|
||||||
|
table.insert(matchingItems, matchingItemID)
|
||||||
|
matchingItemsResult:NextRow()
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Send the matching transmogs to the client
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "ReceiveMatchingAppearances", itemID, matchingItems)
|
||||||
|
end
|
||||||
|
|
||||||
|
function TransmogrificationHandler.SendCollectedTransmogItemIDs(player)
|
||||||
|
local accountGUID = player:GetAccountId()
|
||||||
|
|
||||||
|
-- Query to retrieve collected transmog display IDs
|
||||||
|
local collectedDisplayIDsQuery = "SELECT DISTINCT display_id FROM account_transmog WHERE account_id = " .. accountGUID .. ";"
|
||||||
|
local displayIDsResult = AuthDBQuery(collectedDisplayIDsQuery)
|
||||||
|
|
||||||
|
if not displayIDsResult then
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "ReceiveCollectedAppearances", {}, 0)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Count the unique appearances.
|
||||||
|
local uniqueAppearancesCount = displayIDsResult:GetRowCount()
|
||||||
|
|
||||||
|
-- Collect the display IDs into a table
|
||||||
|
local collectedDisplayIDs = {}
|
||||||
|
for i = 1, displayIDsResult:GetRowCount() do
|
||||||
|
local currentRow = displayIDsResult:GetRow()
|
||||||
|
local displayID = currentRow["display_id"]
|
||||||
|
table.insert(collectedDisplayIDs, displayID)
|
||||||
|
displayIDsResult:NextRow()
|
||||||
|
end
|
||||||
|
|
||||||
|
local collectedAppearances = {}
|
||||||
|
|
||||||
|
-- For each display ID, find all items that share the appearance
|
||||||
|
for _, displayID in ipairs(collectedDisplayIDs) do
|
||||||
|
local itemsWithDisplayQuery = "SELECT entry FROM item_template WHERE displayid = " .. displayID .. ";"
|
||||||
|
local itemsResult = WorldDBQuery(itemsWithDisplayQuery)
|
||||||
|
|
||||||
|
if itemsResult then
|
||||||
|
for j = 1, itemsResult:GetRowCount() do
|
||||||
|
local itemRow = itemsResult:GetRow()
|
||||||
|
local itemID = itemRow["entry"]
|
||||||
|
table.insert(collectedAppearances, itemID)
|
||||||
|
itemsResult:NextRow()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Send the collected transmogs to the client
|
||||||
|
AIO.Handle(player, "TransmogrificationServer", "ReceiveCollectedAppearances", collectedAppearances, uniqueAppearancesCount)
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterPlayerEvent(1, Transmog_OnCharacterCreate)
|
||||||
|
RegisterPlayerEvent(2, Transmog_OnCharacterDelete)
|
||||||
|
RegisterPlayerEvent(3, Transmog_OnCharacterLogin)
|
||||||
|
|
||||||
|
RegisterPlayerEvent(29, Transmog_OnEquipItem)
|
||||||
|
|
||||||
|
RegisterPlayerEvent(30, function(event, player, bag, slot)
|
||||||
|
if bag == 255 then
|
||||||
|
TransmogrificationHandler.OnUnequipItem(player)
|
||||||
|
end
|
||||||
|
end)
|
||||||
|
|
||||||
|
if ADD_NEWLY_LOOTED_ITEMS_TO_THE_TRANSMOG_LIST then
|
||||||
|
RegisterPlayerEvent(32, Transmog_OnLootItem)
|
||||||
|
RegisterPlayerEvent(51, Transmog_OnLootItem)
|
||||||
|
RegisterPlayerEvent(52, Transmog_OnLootItem)
|
||||||
|
RegisterPlayerEvent(53, Transmog_OnLootItem)
|
||||||
|
RegisterPlayerEvent(56, Transmog_OnLootItem)
|
||||||
|
end
|
||||||
|
|
||||||
|
if ADD_QUEST_REWARD_ITEMS_TO_THE_TRANSMOG_LIST then
|
||||||
|
RegisterPlayerEvent(54, Transmog_OnQuestComplete)
|
||||||
|
end
|
||||||
|
|
||||||
|
RegisterPlayerEvent(42, function(event, player, command)
|
||||||
|
if command:lower() == "transmog sync" then
|
||||||
|
TransmogrificationHandler.SendCollectedTransmogItemIDs(player)
|
||||||
|
return false
|
||||||
|
end
|
||||||
|
return true
|
||||||
|
end)
|
||||||
|
|
||||||
|
print("[Eluna] Transmog System loaded successfully.")
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
* text=auto
|
||||||
|
*.cpp text eol=lf
|
||||||
|
*.h text eol=lf
|
||||||
|
*.md text eol=lf
|
||||||
|
*.dist text eol=lf
|
||||||
|
*.patch text eol=lf
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Build and editor artifacts
|
||||||
|
.vs/
|
||||||
|
.vscode/
|
||||||
|
build/
|
||||||
|
cmake-build-*/
|
||||||
|
*.user
|
||||||
|
*.suo
|
||||||
|
*.VC.db
|
||||||
|
*.VC.VC.opendb
|
||||||
|
*.obj
|
||||||
|
*.pdb
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Active server config
|
||||||
|
conf/AccountBound.conf
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
# Changelog
|
||||||
|
|
||||||
|
All notable changes to AccountBound are documented in this file.
|
||||||
|
|
||||||
|
## 1.1.0 - 2026-06-06
|
||||||
|
|
||||||
|
- Changed account sharing to silent database-only synchronization.
|
||||||
|
- Removed live `learnSpell()` calls for mounts and companion pets.
|
||||||
|
- Removed live replay of achievements, titles, reputations and friend lists.
|
||||||
|
- Added `SyncOnCreate` support to every shared category.
|
||||||
|
- Enabled startup friend backfill by default.
|
||||||
|
- Removed the optional achievement live-sync core patch.
|
||||||
|
|
||||||
|
## 1.0.0 - 2026-06-06
|
||||||
|
|
||||||
|
- Initial public release.
|
||||||
|
- Added account-wide achievements.
|
||||||
|
- Added account-wide mounts with riding, class and faction checks.
|
||||||
|
- Added account-wide vanity companion pets.
|
||||||
|
- Added account-wide titles.
|
||||||
|
- Added monotonic account-wide reputation sharing.
|
||||||
|
- Added account-wide profession ranks, progress and recipes.
|
||||||
|
- Added configurable support for all 11 primary professions.
|
||||||
|
- Added account-wide friend lists and notes.
|
||||||
|
- Added per-category switches, filters and startup backfill.
|
||||||
|
- Added compatibility with stock AzerothCore and mod-playerbots.
|
||||||
|
- Added optional instant achievement import patch.
|
||||||
@@ -0,0 +1,339 @@
|
|||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
Version 2, June 1991
|
||||||
|
|
||||||
|
Copyright (C) 1989, 1991 Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The licenses for most software are designed to take away your
|
||||||
|
freedom to share and change it. By contrast, the GNU General Public
|
||||||
|
License is intended to guarantee your freedom to share and change free
|
||||||
|
software--to make sure the software is free for all its users. This
|
||||||
|
General Public License applies to most of the Free Software
|
||||||
|
Foundation's software and to any other program whose authors commit to
|
||||||
|
using it. (Some other Free Software Foundation software is covered by
|
||||||
|
the GNU Lesser General Public License instead.) You can apply it to
|
||||||
|
your programs, too.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
this service if you wish), that you receive source code or can get it
|
||||||
|
if you want it, that you can change the software or use pieces of it
|
||||||
|
in new free programs; and that you know you can do these things.
|
||||||
|
|
||||||
|
To protect your rights, we need to make restrictions that forbid
|
||||||
|
anyone to deny you these rights or to ask you to surrender the rights.
|
||||||
|
These restrictions translate to certain responsibilities for you if you
|
||||||
|
distribute copies of the software, or if you modify it.
|
||||||
|
|
||||||
|
For example, if you distribute copies of such a program, whether
|
||||||
|
gratis or for a fee, you must give the recipients all the rights that
|
||||||
|
you have. You must make sure that they, too, receive or can get the
|
||||||
|
source code. And you must show them these terms so they know their
|
||||||
|
rights.
|
||||||
|
|
||||||
|
We protect your rights with two steps: (1) copyright the software, and
|
||||||
|
(2) offer you this license which gives you legal permission to copy,
|
||||||
|
distribute and/or modify the software.
|
||||||
|
|
||||||
|
Also, for each author's protection and ours, we want to make certain
|
||||||
|
that everyone understands that there is no warranty for this free
|
||||||
|
software. If the software is modified by someone else and passed on, we
|
||||||
|
want its recipients to know that what they have is not the original, so
|
||||||
|
that any problems introduced by others will not reflect on the original
|
||||||
|
authors' reputations.
|
||||||
|
|
||||||
|
Finally, any free program is threatened constantly by software
|
||||||
|
patents. We wish to avoid the danger that redistributors of a free
|
||||||
|
program will individually obtain patent licenses, in effect making the
|
||||||
|
program proprietary. To prevent this, we have made it clear that any
|
||||||
|
patent must be licensed for everyone's free use or not licensed at all.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
GNU GENERAL PUBLIC LICENSE
|
||||||
|
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
|
||||||
|
|
||||||
|
0. This License applies to any program or other work which contains
|
||||||
|
a notice placed by the copyright holder saying it may be distributed
|
||||||
|
under the terms of this General Public License. The "Program", below,
|
||||||
|
refers to any such program or work, and a "work based on the Program"
|
||||||
|
means either the Program or any derivative work under copyright law:
|
||||||
|
that is to say, a work containing the Program or a portion of it,
|
||||||
|
either verbatim or with modifications and/or translated into another
|
||||||
|
language. (Hereinafter, translation is included without limitation in
|
||||||
|
the term "modification".) Each licensee is addressed as "you".
|
||||||
|
|
||||||
|
Activities other than copying, distribution and modification are not
|
||||||
|
covered by this License; they are outside its scope. The act of
|
||||||
|
running the Program is not restricted, and the output from the Program
|
||||||
|
is covered only if its contents constitute a work based on the
|
||||||
|
Program (independent of having been made by running the Program).
|
||||||
|
Whether that is true depends on what the Program does.
|
||||||
|
|
||||||
|
1. You may copy and distribute verbatim copies of the Program's
|
||||||
|
source code as you receive it, in any medium, provided that you
|
||||||
|
conspicuously and appropriately publish on each copy an appropriate
|
||||||
|
copyright notice and disclaimer of warranty; keep intact all the
|
||||||
|
notices that refer to this License and to the absence of any warranty;
|
||||||
|
and give any other recipients of the Program a copy of this License
|
||||||
|
along with the Program.
|
||||||
|
|
||||||
|
You may charge a fee for the physical act of transferring a copy, and
|
||||||
|
you may at your option offer warranty protection in exchange for a fee.
|
||||||
|
|
||||||
|
2. You may modify your copy or copies of the Program or any portion
|
||||||
|
of it, thus forming a work based on the Program, and copy and
|
||||||
|
distribute such modifications or work under the terms of Section 1
|
||||||
|
above, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) You must cause the modified files to carry prominent notices
|
||||||
|
stating that you changed the files and the date of any change.
|
||||||
|
|
||||||
|
b) You must cause any work that you distribute or publish, that in
|
||||||
|
whole or in part contains or is derived from the Program or any
|
||||||
|
part thereof, to be licensed as a whole at no charge to all third
|
||||||
|
parties under the terms of this License.
|
||||||
|
|
||||||
|
c) If the modified program normally reads commands interactively
|
||||||
|
when run, you must cause it, when started running for such
|
||||||
|
interactive use in the most ordinary way, to print or display an
|
||||||
|
announcement including an appropriate copyright notice and a
|
||||||
|
notice that there is no warranty (or else, saying that you provide
|
||||||
|
a warranty) and that users may redistribute the program under
|
||||||
|
these conditions, and telling the user how to view a copy of this
|
||||||
|
License. (Exception: if the Program itself is interactive but
|
||||||
|
does not normally print such an announcement, your work based on
|
||||||
|
the Program is not required to print an announcement.)
|
||||||
|
|
||||||
|
These requirements apply to the modified work as a whole. If
|
||||||
|
identifiable sections of that work are not derived from the Program,
|
||||||
|
and can be reasonably considered independent and separate works in
|
||||||
|
themselves, then this License, and its terms, do not apply to those
|
||||||
|
sections when you distribute them as separate works. But when you
|
||||||
|
distribute the same sections as part of a whole which is a work based
|
||||||
|
on the Program, the distribution of the whole must be on the terms of
|
||||||
|
this License, whose permissions for other licensees extend to the
|
||||||
|
entire whole, and thus to each and every part regardless of who wrote it.
|
||||||
|
|
||||||
|
Thus, it is not the intent of this section to claim rights or contest
|
||||||
|
your rights to work written entirely by you; rather, the intent is to
|
||||||
|
exercise the right to control the distribution of derivative or
|
||||||
|
collective works based on the Program.
|
||||||
|
|
||||||
|
In addition, mere aggregation of another work not based on the Program
|
||||||
|
with the Program (or with a work based on the Program) on a volume of
|
||||||
|
a storage or distribution medium does not bring the other work under
|
||||||
|
the scope of this License.
|
||||||
|
|
||||||
|
3. You may copy and distribute the Program (or a work based on it,
|
||||||
|
under Section 2) in object code or executable form under the terms of
|
||||||
|
Sections 1 and 2 above provided that you also do one of the following:
|
||||||
|
|
||||||
|
a) Accompany it with the complete corresponding machine-readable
|
||||||
|
source code, which must be distributed under the terms of Sections
|
||||||
|
1 and 2 above on a medium customarily used for software interchange; or,
|
||||||
|
|
||||||
|
b) Accompany it with a written offer, valid for at least three
|
||||||
|
years, to give any third party, for a charge no more than your
|
||||||
|
cost of physically performing source distribution, a complete
|
||||||
|
machine-readable copy of the corresponding source code, to be
|
||||||
|
distributed under the terms of Sections 1 and 2 above on a medium
|
||||||
|
customarily used for software interchange; or,
|
||||||
|
|
||||||
|
c) Accompany it with the information you received as to the offer
|
||||||
|
to distribute corresponding source code. (This alternative is
|
||||||
|
allowed only for noncommercial distribution and only if you
|
||||||
|
received the program in object code or executable form with such
|
||||||
|
an offer, in accord with Subsection b above.)
|
||||||
|
|
||||||
|
The source code for a work means the preferred form of the work for
|
||||||
|
making modifications to it. For an executable work, complete source
|
||||||
|
code means all the source code for all modules it contains, plus any
|
||||||
|
associated interface definition files, plus the scripts used to
|
||||||
|
control compilation and installation of the executable. However, as a
|
||||||
|
special exception, the source code distributed need not include
|
||||||
|
anything that is normally distributed (in either source or binary
|
||||||
|
form) with the major components (compiler, kernel, and so on) of the
|
||||||
|
operating system on which the executable runs, unless that component
|
||||||
|
itself accompanies the executable.
|
||||||
|
|
||||||
|
If distribution of executable or object code is made by offering
|
||||||
|
access to copy from a designated place, then offering equivalent
|
||||||
|
access to copy the source code from the same place counts as
|
||||||
|
distribution of the source code, even though third parties are not
|
||||||
|
compelled to copy the source along with the object code.
|
||||||
|
|
||||||
|
4. You may not copy, modify, sublicense, or distribute the Program
|
||||||
|
except as expressly provided under this License. Any attempt
|
||||||
|
otherwise to copy, modify, sublicense or distribute the Program is
|
||||||
|
void, and will automatically terminate your rights under this License.
|
||||||
|
However, parties who have received copies, or rights, from you under
|
||||||
|
this License will not have their licenses terminated so long as such
|
||||||
|
parties remain in full compliance.
|
||||||
|
|
||||||
|
5. You are not required to accept this License, since you have not
|
||||||
|
signed it. However, nothing else grants you permission to modify or
|
||||||
|
distribute the Program or its derivative works. These actions are
|
||||||
|
prohibited by law if you do not accept this License. Therefore, by
|
||||||
|
modifying or distributing the Program (or any work based on the
|
||||||
|
Program), you indicate your acceptance of this License to do so, and
|
||||||
|
all its terms and conditions for copying, distributing or modifying
|
||||||
|
the Program or works based on it.
|
||||||
|
|
||||||
|
6. Each time you redistribute the Program (or any work based on the
|
||||||
|
Program), the recipient automatically receives a license from the
|
||||||
|
original licensor to copy, distribute or modify the Program subject to
|
||||||
|
these terms and conditions. You may not impose any further
|
||||||
|
restrictions on the recipients' exercise of the rights granted herein.
|
||||||
|
You are not responsible for enforcing compliance by third parties to
|
||||||
|
this License.
|
||||||
|
|
||||||
|
7. If, as a consequence of a court judgment or allegation of patent
|
||||||
|
infringement or for any other reason (not limited to patent issues),
|
||||||
|
conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot
|
||||||
|
distribute so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you
|
||||||
|
may not distribute the Program at all. For example, if a patent
|
||||||
|
license would not permit royalty-free redistribution of the Program by
|
||||||
|
all those who receive copies directly or indirectly through you, then
|
||||||
|
the only way you could satisfy both it and this License would be to
|
||||||
|
refrain entirely from distribution of the Program.
|
||||||
|
|
||||||
|
If any portion of this section is held invalid or unenforceable under
|
||||||
|
any particular circumstance, the balance of the section is intended to
|
||||||
|
apply and the section as a whole is intended to apply in other
|
||||||
|
circumstances.
|
||||||
|
|
||||||
|
It is not the purpose of this section to induce you to infringe any
|
||||||
|
patents or other property right claims or to contest validity of any
|
||||||
|
such claims; this section has the sole purpose of protecting the
|
||||||
|
integrity of the free software distribution system, which is
|
||||||
|
implemented by public license practices. Many people have made
|
||||||
|
generous contributions to the wide range of software distributed
|
||||||
|
through that system in reliance on consistent application of that
|
||||||
|
system; it is up to the author/donor to decide if he or she is willing
|
||||||
|
to distribute software through any other system and a licensee cannot
|
||||||
|
impose that choice.
|
||||||
|
|
||||||
|
This section is intended to make thoroughly clear what is believed to
|
||||||
|
be a consequence of the rest of this License.
|
||||||
|
|
||||||
|
8. If the distribution and/or use of the Program is restricted in
|
||||||
|
certain countries either by patents or by copyrighted interfaces, the
|
||||||
|
original copyright holder who places the Program under this License
|
||||||
|
may add an explicit geographical distribution limitation excluding
|
||||||
|
those countries, so that distribution is permitted only in or among
|
||||||
|
countries not thus excluded. In such case, this License incorporates
|
||||||
|
the limitation as if written in the body of this License.
|
||||||
|
|
||||||
|
9. The Free Software Foundation may publish revised and/or new versions
|
||||||
|
of the General Public License from time to time. Such new versions will
|
||||||
|
be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the Program
|
||||||
|
specifies a version number of this License which applies to it and "any
|
||||||
|
later version", you have the option of following the terms and conditions
|
||||||
|
either of that version or of any later version published by the Free
|
||||||
|
Software Foundation. If the Program does not specify a version number of
|
||||||
|
this License, you may choose any version ever published by the Free Software
|
||||||
|
Foundation.
|
||||||
|
|
||||||
|
10. If you wish to incorporate parts of the Program into other free
|
||||||
|
programs whose distribution conditions are different, write to the author
|
||||||
|
to ask for permission. For software which is copyrighted by the Free
|
||||||
|
Software Foundation, write to the Free Software Foundation; we sometimes
|
||||||
|
make exceptions for this. Our decision will be guided by the two goals
|
||||||
|
of preserving the free status of all derivatives of our free software and
|
||||||
|
of promoting the sharing and reuse of software generally.
|
||||||
|
|
||||||
|
NO WARRANTY
|
||||||
|
|
||||||
|
11. BECAUSE THE PROGRAM IS LICENSED FREE OF CHARGE, THERE IS NO WARRANTY
|
||||||
|
FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN
|
||||||
|
OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES
|
||||||
|
PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED
|
||||||
|
OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
|
||||||
|
MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS
|
||||||
|
TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE
|
||||||
|
PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING,
|
||||||
|
REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
12. IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MAY MODIFY AND/OR
|
||||||
|
REDISTRIBUTE THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING
|
||||||
|
OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED
|
||||||
|
TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY
|
||||||
|
YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER
|
||||||
|
PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE
|
||||||
|
POSSIBILITY OF SUCH DAMAGES.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
convey the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software; you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU General Public License as published by
|
||||||
|
the Free Software Foundation; either version 2 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU General Public License along
|
||||||
|
with this program; if not, write to the Free Software Foundation, Inc.,
|
||||||
|
51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If the program is interactive, make it output a short notice like this
|
||||||
|
when it starts in an interactive mode:
|
||||||
|
|
||||||
|
Gnomovision version 69, Copyright (C) year name of author
|
||||||
|
Gnomovision comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||||
|
This is free software, and you are welcome to redistribute it
|
||||||
|
under certain conditions; type `show c' for details.
|
||||||
|
|
||||||
|
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||||
|
parts of the General Public License. Of course, the commands you use may
|
||||||
|
be called something other than `show w' and `show c'; they could even be
|
||||||
|
mouse-clicks or menu items--whatever suits your program.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or your
|
||||||
|
school, if any, to sign a "copyright disclaimer" for the program, if
|
||||||
|
necessary. Here is a sample; alter the names:
|
||||||
|
|
||||||
|
Yoyodyne, Inc., hereby disclaims all copyright interest in the program
|
||||||
|
`Gnomovision' (which makes passes at compilers) written by James Hacker.
|
||||||
|
|
||||||
|
<signature of Ty Coon>, 1 April 1989
|
||||||
|
Ty Coon, President of Vice
|
||||||
|
|
||||||
|
This General Public License does not permit incorporating your program into
|
||||||
|
proprietary programs. If your program is a subroutine library, you may
|
||||||
|
consider it more useful to permit linking proprietary applications with the
|
||||||
|
library. If this is what you want to do, use the GNU Lesser General
|
||||||
|
Public License instead of this License.
|
||||||
@@ -0,0 +1,482 @@
|
|||||||
|
# AccountBound
|
||||||
|
|
||||||
|
AccountBound is an AzerothCore module for WotLK 3.3.5a that shares selected
|
||||||
|
collections and progression systems between eligible characters on the same
|
||||||
|
game account.
|
||||||
|
|
||||||
|
It combines achievements, mounts, companion pets, titles, reputations,
|
||||||
|
professions and friends into one module with one configuration file. Every
|
||||||
|
category can be enabled, disabled and filtered independently.
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- Account-wide achievements with faction conversion support.
|
||||||
|
- Account-wide mounts with riding, class and faction requirement checks.
|
||||||
|
- Account-wide vanity companion pets.
|
||||||
|
- Account-wide titles with faction conversion support.
|
||||||
|
- Account-wide reputations that only copy higher standings.
|
||||||
|
- Account-wide profession ranks, skill progress and recipes.
|
||||||
|
- Configurable primary-profession limit up to all 11 WotLK professions.
|
||||||
|
- Account-wide friend lists and friend notes.
|
||||||
|
- Per-category startup backfill for existing realms.
|
||||||
|
- Silent SQL synchronization without learned-spell or collection spam.
|
||||||
|
- Character creation seeding before the character's first login.
|
||||||
|
- Per-category ID allow lists and block lists.
|
||||||
|
- One master switch and one module configuration file.
|
||||||
|
- Uses AzerothCore's existing character tables.
|
||||||
|
- No custom SQL tables are required.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
- AzerothCore WotLK 3.3.5a
|
||||||
|
- AzerothCore `master`
|
||||||
|
- `mod-playerbots/azerothcore-wotlk`
|
||||||
|
- Built as a normal source module under `modules/`
|
||||||
|
- Tested with a Release worldserver build on Windows
|
||||||
|
|
||||||
|
The module uses hooks currently available in AzerothCore. Stock AzerothCore
|
||||||
|
does not require a core patch.
|
||||||
|
|
||||||
|
## Silent Database Synchronization
|
||||||
|
|
||||||
|
AccountBound writes shared data directly to AzerothCore's existing character
|
||||||
|
tables. It does not call `learnSpell()` or replay achievements, titles,
|
||||||
|
reputation gains or friend notifications during login.
|
||||||
|
|
||||||
|
The synchronization lifecycle is:
|
||||||
|
|
||||||
|
1. `StartupBackfill` migrates characters that already exist before players can
|
||||||
|
log in.
|
||||||
|
2. `SyncOnCreate` seeds a new character after its initial database save and
|
||||||
|
before its first login.
|
||||||
|
3. Normal gameplay hooks persist newly earned data to the other characters on
|
||||||
|
the account.
|
||||||
|
4. The target character loads the already-persisted rows during its next normal
|
||||||
|
login.
|
||||||
|
|
||||||
|
This prevents login spam. If a requirement changes while a character is
|
||||||
|
already online, such as gaining a higher riding rank, the newly eligible rows
|
||||||
|
are stored immediately and become visible after one relog.
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
Clone the repository into the AzerothCore modules directory:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd azerothcore-wotlk/modules
|
||||||
|
git clone https://github.com/AlsoNotMehh/AccountBound.git
|
||||||
|
```
|
||||||
|
|
||||||
|
Re-run CMake and rebuild `worldserver`.
|
||||||
|
|
||||||
|
Copy:
|
||||||
|
|
||||||
|
```text
|
||||||
|
modules/AccountBound/conf/AccountBound.conf.dist
|
||||||
|
```
|
||||||
|
|
||||||
|
to:
|
||||||
|
|
||||||
|
```text
|
||||||
|
configs/modules/AccountBound.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
Restart `worldserver`.
|
||||||
|
|
||||||
|
The startup log should contain:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Using modules configuration:
|
||||||
|
> AccountBound.conf
|
||||||
|
```
|
||||||
|
|
||||||
|
## Quick Config
|
||||||
|
|
||||||
|
The master switch controls the entire module:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Enable = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Each system has an independent switch:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Achievements.Enable = 1
|
||||||
|
AccountBound.Mounts.Enable = 1
|
||||||
|
AccountBound.Pets.Enable = 1
|
||||||
|
AccountBound.Titles.Enable = 1
|
||||||
|
AccountBound.Reputations.Enable = 1
|
||||||
|
AccountBound.Professions.Enable = 1
|
||||||
|
AccountBound.Friends.Enable = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Set any category to `0` to keep it character-specific.
|
||||||
|
|
||||||
|
## Shared Data
|
||||||
|
|
||||||
|
| Category | Shared data |
|
||||||
|
| --- | --- |
|
||||||
|
| Achievements | Completed achievements and completion date |
|
||||||
|
| Mounts | Learned mount spells |
|
||||||
|
| Pets | Vanity companion and minipet spells |
|
||||||
|
| Titles | Known character titles |
|
||||||
|
| Reputations | Highest eligible standing and reputation flags |
|
||||||
|
| Professions | Ranks, current/max skill and learned recipes |
|
||||||
|
| Friends | Friend list entries and notes |
|
||||||
|
|
||||||
|
The module intentionally does not share quests, inventory, gold, currencies,
|
||||||
|
honor, arena rating, instance lockouts, cooldowns, talents, glyphs, action bars,
|
||||||
|
mail, auctions or hunter stable pets.
|
||||||
|
|
||||||
|
Those systems contain character-specific progression, ownership or class data
|
||||||
|
and should not be merged automatically.
|
||||||
|
|
||||||
|
## ID Filters
|
||||||
|
|
||||||
|
Achievements, mounts, pets, titles and reputations support:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.CATEGORY.AllowList = all
|
||||||
|
AccountBound.CATEGORY.BlockList = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `CATEGORY` with:
|
||||||
|
|
||||||
|
```text
|
||||||
|
Achievements
|
||||||
|
Mounts
|
||||||
|
Pets
|
||||||
|
Titles
|
||||||
|
Reputations
|
||||||
|
```
|
||||||
|
|
||||||
|
`AllowList = all` permits every valid entry.
|
||||||
|
|
||||||
|
`AllowList = none` permits no entries.
|
||||||
|
|
||||||
|
A comma-separated allow list shares only those IDs:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Mounts.AllowList = 458,470,6648
|
||||||
|
```
|
||||||
|
|
||||||
|
The block list always wins:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Mounts.AllowList = all
|
||||||
|
AccountBound.Mounts.BlockList = 48778,54729
|
||||||
|
```
|
||||||
|
|
||||||
|
Converted Horde or Alliance equivalents must also pass the configured filter.
|
||||||
|
|
||||||
|
## Achievements
|
||||||
|
|
||||||
|
Recommended defaults:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Achievements.Enable = 1
|
||||||
|
AccountBound.Achievements.SyncOnCreate = 1
|
||||||
|
AccountBound.Achievements.SyncRealmFirst = 0
|
||||||
|
AccountBound.Achievements.SyncHidden = 0
|
||||||
|
AccountBound.Achievements.ConvertFactionSpecific = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Realm First achievements are excluded by default because they represent a
|
||||||
|
specific character or realm race.
|
||||||
|
|
||||||
|
Faction-specific achievements are converted with AzerothCore's faction-change
|
||||||
|
mapping when `ConvertFactionSpecific = 1`.
|
||||||
|
|
||||||
|
Achievements are inserted into `character_achievement`. They are never replayed
|
||||||
|
through the live achievement reward path, so the module does not resend
|
||||||
|
announcements, mail, items or title notifications.
|
||||||
|
|
||||||
|
## Mounts
|
||||||
|
|
||||||
|
Recommended defaults:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Mounts.Enable = 1
|
||||||
|
AccountBound.Mounts.SyncOnCreate = 1
|
||||||
|
AccountBound.Mounts.RespectFactionRestrictions = 1
|
||||||
|
AccountBound.Mounts.ConvertFactionSpecific = 1
|
||||||
|
AccountBound.Mounts.RequireRiding = 1
|
||||||
|
AccountBound.Mounts.RequireClass = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
The module discovers mount spells from spell data and item templates.
|
||||||
|
|
||||||
|
It can enforce:
|
||||||
|
|
||||||
|
- Riding skill requirements.
|
||||||
|
- Class restrictions for class mounts.
|
||||||
|
- Horde and Alliance restrictions.
|
||||||
|
- Same-faction restrictions for class mounts.
|
||||||
|
|
||||||
|
Invalid shared mount rows can be cleaned at startup:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Mounts.CleanupInvalid = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Companion Pets
|
||||||
|
|
||||||
|
The pets category shares vanity companions only:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Pets.SyncOnCreate = 1
|
||||||
|
AccountBound.Pets.IncludeCompanionSkillLine = 1
|
||||||
|
AccountBound.Pets.IncludeMinipetSummons = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
It detects WotLK companion skill-line spells and custom summons using
|
||||||
|
`SUMMON_TYPE_MINIPET`.
|
||||||
|
|
||||||
|
Hunter stable pets, permanent combat pets and pet talent data are not shared.
|
||||||
|
|
||||||
|
## Titles
|
||||||
|
|
||||||
|
Recommended defaults:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Titles.Enable = 1
|
||||||
|
AccountBound.Titles.SyncOnCreate = 1
|
||||||
|
AccountBound.Titles.SyncOnSave = 1
|
||||||
|
AccountBound.Titles.SyncRealmFirst = 0
|
||||||
|
AccountBound.Titles.ConvertFactionSpecific = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Realm First reward titles are excluded by default. Faction-specific titles are
|
||||||
|
converted using AzerothCore's faction-change title mappings.
|
||||||
|
|
||||||
|
## Reputations
|
||||||
|
|
||||||
|
Recommended public-realm defaults:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Reputations.Enable = 1
|
||||||
|
AccountBound.Reputations.SyncOnCreate = 1
|
||||||
|
AccountBound.Reputations.SyncOnChange = 1
|
||||||
|
AccountBound.Reputations.SameFactionOnly = 1
|
||||||
|
AccountBound.Reputations.ConvertFactionSpecific = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Reputation sync is monotonic: it copies a standing only when it improves the
|
||||||
|
target character. It never lowers another character's standing.
|
||||||
|
|
||||||
|
Base reputation differences for race and class are respected. Cross-faction
|
||||||
|
sharing can convert supported faction pairs.
|
||||||
|
|
||||||
|
## Professions
|
||||||
|
|
||||||
|
The default configuration allows all 11 WotLK primary professions:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Professions.MaxPrimaryProfessions = 11
|
||||||
|
AccountBound.Professions.Primary.AllowList = all
|
||||||
|
AccountBound.Professions.Primary.BlockList = ""
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted English profession names:
|
||||||
|
|
||||||
|
```text
|
||||||
|
alchemy, blacksmithing, enchanting, engineering, herbalism,
|
||||||
|
inscription, jewelcrafting, leatherworking, mining, skinning, tailoring
|
||||||
|
```
|
||||||
|
|
||||||
|
Accepted Spanish aliases:
|
||||||
|
|
||||||
|
```text
|
||||||
|
alquimia, herreria, encantamiento, ingenieria, herboristeria,
|
||||||
|
inscripcion, joyeria, peleteria, mineria, desuello, sastreria
|
||||||
|
```
|
||||||
|
|
||||||
|
Skill IDs are also accepted:
|
||||||
|
|
||||||
|
```text
|
||||||
|
171,164,333,202,182,773,755,165,186,393,197
|
||||||
|
```
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Professions.Primary.AllowList = alchemy, enchanting, tailoring
|
||||||
|
AccountBound.Professions.MaxPrimaryProfessions = 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Strict allow-list enforcement is disabled by default:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Professions.Primary.EnforceAllowedList = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
Warning: enabling it can remove blocked professions from existing characters.
|
||||||
|
|
||||||
|
Account-wide profession settings:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Professions.SyncOnCreate = 1
|
||||||
|
AccountBound.Professions.SyncSkillProgress = 1
|
||||||
|
AccountBound.Professions.SyncProfessionRanks = 1
|
||||||
|
AccountBound.Professions.SyncRecipes = 1
|
||||||
|
AccountBound.Professions.SyncSkillGrantedSpells = 1
|
||||||
|
AccountBound.Professions.IncludeSecondaryProfessions = 1
|
||||||
|
AccountBound.Professions.RequireRecipeSkill = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
Custom recipe spells can be explicitly allowed or blocked:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Professions.SpellAllowList = 900001,900002
|
||||||
|
AccountBound.Professions.SpellBlockList = 20219,20222
|
||||||
|
```
|
||||||
|
|
||||||
|
## Friends
|
||||||
|
|
||||||
|
The friends category mirrors the normal character friend list across every
|
||||||
|
character on an account:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Friends.SyncOnCreate = 1
|
||||||
|
AccountBound.Friends.SyncOnlineChanges = 1
|
||||||
|
AccountBound.Friends.SyncIntervalSeconds = 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Friend notes are shared. Characters belonging to the same account are removed
|
||||||
|
from the friend list to avoid self-friend entries.
|
||||||
|
|
||||||
|
Cross-faction friends are allowed by default:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Friends.SameFactionOnly = 0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Startup Backfill
|
||||||
|
|
||||||
|
Every progression category has a `StartupBackfill` setting.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Achievements.StartupBackfill = 1
|
||||||
|
AccountBound.Mounts.StartupBackfill = 1
|
||||||
|
AccountBound.Pets.StartupBackfill = 1
|
||||||
|
AccountBound.Titles.StartupBackfill = 1
|
||||||
|
AccountBound.Reputations.StartupBackfill = 1
|
||||||
|
AccountBound.Professions.StartupBackfill = 1
|
||||||
|
AccountBound.Friends.StartupBackfill = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
For an existing realm:
|
||||||
|
|
||||||
|
1. Back up the characters database.
|
||||||
|
2. Enable the categories you want.
|
||||||
|
3. Enable their startup backfill options.
|
||||||
|
4. Start `worldserver` once.
|
||||||
|
5. Wait for every AccountBound startup message.
|
||||||
|
6. Stop `worldserver`.
|
||||||
|
7. Set the startup backfill options back to `0`.
|
||||||
|
8. Start the server normally.
|
||||||
|
|
||||||
|
Backfill uses idempotent inserts or monotonic updates, but it can touch many
|
||||||
|
rows on a large realm.
|
||||||
|
|
||||||
|
Version 1.1 replaces the old `SyncOnLogin` options with `SyncOnCreate`.
|
||||||
|
Existing custom configuration files should be updated to use the new keys.
|
||||||
|
|
||||||
|
## Safe Public-Realm Defaults
|
||||||
|
|
||||||
|
```ini
|
||||||
|
AccountBound.Enable = 1
|
||||||
|
|
||||||
|
AccountBound.Achievements.SyncRealmFirst = 0
|
||||||
|
AccountBound.Achievements.SyncHidden = 0
|
||||||
|
|
||||||
|
AccountBound.Mounts.RespectFactionRestrictions = 1
|
||||||
|
AccountBound.Mounts.RequireRiding = 1
|
||||||
|
AccountBound.Mounts.RequireClass = 1
|
||||||
|
|
||||||
|
AccountBound.Titles.SyncRealmFirst = 0
|
||||||
|
|
||||||
|
AccountBound.Reputations.SameFactionOnly = 1
|
||||||
|
AccountBound.Reputations.SyncUnpairedCrossFaction = 0
|
||||||
|
|
||||||
|
AccountBound.Professions.Primary.EnforceAllowedList = 0
|
||||||
|
AccountBound.Professions.RequireRecipeSkill = 1
|
||||||
|
|
||||||
|
AccountBound.Friends.StartupBackfill = 1
|
||||||
|
```
|
||||||
|
|
||||||
|
After the initial migration, keep every broad `StartupBackfill` option disabled.
|
||||||
|
|
||||||
|
## Database Tables Used
|
||||||
|
|
||||||
|
The module uses normal AzerothCore tables:
|
||||||
|
|
||||||
|
```text
|
||||||
|
characters
|
||||||
|
character_achievement
|
||||||
|
character_spell
|
||||||
|
character_skills
|
||||||
|
character_reputation
|
||||||
|
character_social
|
||||||
|
```
|
||||||
|
|
||||||
|
No custom tables or SQL installation files are required.
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
If the module is not loaded:
|
||||||
|
|
||||||
|
- Confirm the folder is `modules/AccountBound`.
|
||||||
|
- Re-run CMake after adding the module.
|
||||||
|
- Rebuild `worldserver`.
|
||||||
|
- Confirm `configs/modules/AccountBound.conf` exists.
|
||||||
|
- Confirm `AccountBound.Enable = 1`.
|
||||||
|
|
||||||
|
If a mount is not shared:
|
||||||
|
|
||||||
|
- Confirm the spell passes `AllowList` and `BlockList`.
|
||||||
|
- Check riding, class and faction requirements.
|
||||||
|
- Check `RequireRiding`, `RequireClass` and `RespectFactionRestrictions`.
|
||||||
|
|
||||||
|
If a profession recipe is not shared:
|
||||||
|
|
||||||
|
- Confirm the profession is allowed.
|
||||||
|
- Confirm `SyncRecipes = 1`.
|
||||||
|
- Confirm the target has sufficient skill when `RequireRecipeSkill = 1`.
|
||||||
|
- Add custom recipes to `SpellAllowList`.
|
||||||
|
|
||||||
|
If data was inserted while the target character was already online:
|
||||||
|
|
||||||
|
- Relog once so AzerothCore reloads the updated character tables.
|
||||||
|
|
||||||
|
If startup takes longer than normal:
|
||||||
|
|
||||||
|
- Disable broad `StartupBackfill` options after the initial migration.
|
||||||
|
|
||||||
|
## Files
|
||||||
|
|
||||||
|
```text
|
||||||
|
AccountBound/
|
||||||
|
conf/
|
||||||
|
AccountBound.conf.dist
|
||||||
|
src/
|
||||||
|
AccountBound.h
|
||||||
|
Achievements.cpp
|
||||||
|
Friends.cpp
|
||||||
|
Mounts.cpp
|
||||||
|
Pets.cpp
|
||||||
|
Professions.cpp
|
||||||
|
Reputations.cpp
|
||||||
|
Titles.cpp
|
||||||
|
loader.cpp
|
||||||
|
CHANGELOG.md
|
||||||
|
LICENSE
|
||||||
|
README.md
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
AccountBound is distributed under GPL-2.0 to match the AzerothCore core
|
||||||
|
license.
|
||||||
@@ -0,0 +1,111 @@
|
|||||||
|
#
|
||||||
|
# AccountBound
|
||||||
|
# One configurable account-wide module for AzerothCore WotLK.
|
||||||
|
#
|
||||||
|
|
||||||
|
# Master switch for every category below.
|
||||||
|
AccountBound.Enable = 1
|
||||||
|
|
||||||
|
#
|
||||||
|
# Achievements
|
||||||
|
#
|
||||||
|
AccountBound.Achievements.Enable = 0
|
||||||
|
AccountBound.Achievements.StartupBackfill = 1
|
||||||
|
AccountBound.Achievements.SameFactionOnly = 1
|
||||||
|
AccountBound.Achievements.SyncOnCreate = 1
|
||||||
|
AccountBound.Achievements.SyncRealmFirst = 0
|
||||||
|
AccountBound.Achievements.SyncHidden = 0
|
||||||
|
AccountBound.Achievements.ConvertFactionSpecific = 1
|
||||||
|
AccountBound.Achievements.SyncUnpairedFactionSpecific = 0
|
||||||
|
AccountBound.Achievements.AllowList = all
|
||||||
|
AccountBound.Achievements.BlockList = ""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Mounts
|
||||||
|
#
|
||||||
|
AccountBound.Mounts.Enable = 1
|
||||||
|
AccountBound.Mounts.StartupBackfill = 1
|
||||||
|
AccountBound.Mounts.SyncOnCreate = 1
|
||||||
|
AccountBound.Mounts.RespectFactionRestrictions = 1
|
||||||
|
AccountBound.Mounts.ConvertFactionSpecific = 1
|
||||||
|
AccountBound.Mounts.RequireRiding = 1
|
||||||
|
AccountBound.Mounts.SyncClassMounts = 1
|
||||||
|
AccountBound.Mounts.RequireClass = 1
|
||||||
|
AccountBound.Mounts.ClassMountsSameFactionOnly = 1
|
||||||
|
AccountBound.Mounts.CleanupInvalid = 1
|
||||||
|
AccountBound.Mounts.BackfillOnRidingSkillChange = 1
|
||||||
|
AccountBound.Mounts.AllowList = all
|
||||||
|
AccountBound.Mounts.BlockList = ""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Companion pets
|
||||||
|
# This intentionally shares vanity companions, not hunter stable pets.
|
||||||
|
#
|
||||||
|
AccountBound.Pets.Enable = 1
|
||||||
|
AccountBound.Pets.StartupBackfill = 1
|
||||||
|
AccountBound.Pets.SyncOnCreate = 1
|
||||||
|
AccountBound.Pets.SameFactionOnly = 0
|
||||||
|
AccountBound.Pets.IncludeCompanionSkillLine = 1
|
||||||
|
AccountBound.Pets.IncludeMinipetSummons = 1
|
||||||
|
AccountBound.Pets.AllowList = all
|
||||||
|
AccountBound.Pets.BlockList = ""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Titles
|
||||||
|
#
|
||||||
|
AccountBound.Titles.Enable = 0
|
||||||
|
AccountBound.Titles.StartupBackfill = 1
|
||||||
|
AccountBound.Titles.SyncOnCreate = 1
|
||||||
|
AccountBound.Titles.SyncOnSave = 1
|
||||||
|
AccountBound.Titles.SyncRealmFirst = 0
|
||||||
|
AccountBound.Titles.ConvertFactionSpecific = 1
|
||||||
|
AccountBound.Titles.AllowList = all
|
||||||
|
AccountBound.Titles.BlockList = ""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Reputations
|
||||||
|
# Values only move upward. Faction-specific equivalents are converted.
|
||||||
|
#
|
||||||
|
AccountBound.Reputations.Enable = 0
|
||||||
|
AccountBound.Reputations.StartupBackfill = 1
|
||||||
|
AccountBound.Reputations.SyncOnCreate = 1
|
||||||
|
AccountBound.Reputations.SyncOnChange = 1
|
||||||
|
AccountBound.Reputations.SameFactionOnly = 1
|
||||||
|
AccountBound.Reputations.ConvertFactionSpecific = 1
|
||||||
|
AccountBound.Reputations.SyncUnpairedCrossFaction = 0
|
||||||
|
AccountBound.Reputations.AllowList = all
|
||||||
|
AccountBound.Reputations.BlockList = ""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Professions
|
||||||
|
# Supports English/Spanish names or skill IDs in the primary lists.
|
||||||
|
#
|
||||||
|
AccountBound.Professions.Enable = 0
|
||||||
|
AccountBound.Professions.MaxPrimaryProfessions = 11
|
||||||
|
AccountBound.Professions.Primary.AllowList = all
|
||||||
|
AccountBound.Professions.Primary.BlockList = ""
|
||||||
|
AccountBound.Professions.Primary.CountOnlyAllowed = 1
|
||||||
|
AccountBound.Professions.Primary.EnforceAllowedList = 0
|
||||||
|
AccountBound.Professions.MaxSkillValue = 450
|
||||||
|
AccountBound.Professions.StartupBackfill = 1
|
||||||
|
AccountBound.Professions.SyncOnCreate = 1
|
||||||
|
AccountBound.Professions.SyncOnLearn = 1
|
||||||
|
AccountBound.Professions.IncludeSecondaryProfessions = 1
|
||||||
|
AccountBound.Professions.SyncSkillProgress = 1
|
||||||
|
AccountBound.Professions.SyncProfessionRanks = 1
|
||||||
|
AccountBound.Professions.SyncRecipes = 1
|
||||||
|
AccountBound.Professions.SyncSkillGrantedSpells = 1
|
||||||
|
AccountBound.Professions.RequireRecipeSkill = 1
|
||||||
|
AccountBound.Professions.SpellAllowList = ""
|
||||||
|
AccountBound.Professions.SpellBlockList = ""
|
||||||
|
|
||||||
|
#
|
||||||
|
# Friends
|
||||||
|
# Mirrors the normal friend list and notes across characters on the account.
|
||||||
|
#
|
||||||
|
AccountBound.Friends.Enable = 1
|
||||||
|
AccountBound.Friends.SyncOnCreate = 1
|
||||||
|
AccountBound.Friends.SyncOnlineChanges = 1
|
||||||
|
AccountBound.Friends.SameFactionOnly = 0
|
||||||
|
AccountBound.Friends.SyncIntervalSeconds = 3
|
||||||
|
AccountBound.Friends.StartupBackfill = 1
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
#ifndef ACCOUNT_BOUND_H
|
||||||
|
#define ACCOUNT_BOUND_H
|
||||||
|
|
||||||
|
#include "Config.h"
|
||||||
|
#include "Log.h"
|
||||||
|
|
||||||
|
#include <charconv>
|
||||||
|
#include <cctype>
|
||||||
|
#include <string>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_set>
|
||||||
|
|
||||||
|
namespace AccountBound
|
||||||
|
{
|
||||||
|
struct IdFilter
|
||||||
|
{
|
||||||
|
bool AllowAll = true;
|
||||||
|
std::unordered_set<uint32> Allowed;
|
||||||
|
std::unordered_set<uint32> Blocked;
|
||||||
|
|
||||||
|
bool Allows(uint32 id) const
|
||||||
|
{
|
||||||
|
return id && !Blocked.contains(id) && (AllowAll || Allowed.contains(id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
inline std::string Trim(std::string_view value)
|
||||||
|
{
|
||||||
|
std::size_t first = 0;
|
||||||
|
while (first < value.size() && std::isspace(static_cast<unsigned char>(value[first])))
|
||||||
|
++first;
|
||||||
|
|
||||||
|
std::size_t last = value.size();
|
||||||
|
while (last > first && std::isspace(static_cast<unsigned char>(value[last - 1])))
|
||||||
|
--last;
|
||||||
|
|
||||||
|
return std::string(value.substr(first, last - first));
|
||||||
|
}
|
||||||
|
|
||||||
|
inline void ParseIds(std::string const& raw, std::unordered_set<uint32>& destination)
|
||||||
|
{
|
||||||
|
std::size_t start = 0;
|
||||||
|
while (start <= raw.size())
|
||||||
|
{
|
||||||
|
std::size_t end = raw.find(',', start);
|
||||||
|
if (end == std::string::npos)
|
||||||
|
end = raw.size();
|
||||||
|
|
||||||
|
std::string token = Trim(std::string_view(raw).substr(start, end - start));
|
||||||
|
if (!token.empty())
|
||||||
|
{
|
||||||
|
uint32 id = 0;
|
||||||
|
auto const [ptr, error] = std::from_chars(token.data(), token.data() + token.size(), id);
|
||||||
|
if (error == std::errc() && ptr == token.data() + token.size() && id)
|
||||||
|
destination.insert(id);
|
||||||
|
else
|
||||||
|
LOG_WARN("module.accountbound", "AccountBound: ignored invalid ID '{}' in an AllowList or BlockList.", token);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (end == raw.size())
|
||||||
|
break;
|
||||||
|
|
||||||
|
start = end + 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
inline bool IsCategoryEnabled(std::string_view category, bool defaultValue = true)
|
||||||
|
{
|
||||||
|
if (!sConfigMgr->GetOption<bool>("AccountBound.Enable", true))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return sConfigMgr->GetOption<bool>(
|
||||||
|
"AccountBound." + std::string(category) + ".Enable", defaultValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
inline IdFilter LoadIdFilter(std::string_view category)
|
||||||
|
{
|
||||||
|
IdFilter filter;
|
||||||
|
std::string const prefix = "AccountBound." + std::string(category);
|
||||||
|
std::string const allowList = Trim(sConfigMgr->GetOption<std::string>(prefix + ".AllowList", "all"));
|
||||||
|
|
||||||
|
if (!allowList.empty() && allowList != "all" && allowList != "ALL" && allowList != "*")
|
||||||
|
{
|
||||||
|
filter.AllowAll = false;
|
||||||
|
if (allowList != "none" && allowList != "NONE")
|
||||||
|
ParseIds(allowList, filter.Allowed);
|
||||||
|
}
|
||||||
|
|
||||||
|
ParseIds(sConfigMgr->GetOption<std::string>(prefix + ".BlockList", ""), filter.Blocked);
|
||||||
|
return filter;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#endif
|
||||||
@@ -0,0 +1,378 @@
|
|||||||
|
/*
|
||||||
|
* Account-bound achievements for AzerothCore.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "AccountBound.h"
|
||||||
|
#include "Config.h"
|
||||||
|
#include "DatabaseEnv.h"
|
||||||
|
#include "DBCEnums.h"
|
||||||
|
#include "DBCStores.h"
|
||||||
|
#include "GameTime.h"
|
||||||
|
#include "Log.h"
|
||||||
|
#include "ObjectMgr.h"
|
||||||
|
#include "Player.h"
|
||||||
|
#include "PlayerScript.h"
|
||||||
|
#include "StringFormat.h"
|
||||||
|
#include "WorldScript.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct ModuleConfig
|
||||||
|
{
|
||||||
|
bool Enabled = true;
|
||||||
|
bool StartupBackfill = true;
|
||||||
|
bool SameFactionOnly = false;
|
||||||
|
bool SyncOnCreate = true;
|
||||||
|
bool SyncRealmFirst = false;
|
||||||
|
bool SyncHidden = false;
|
||||||
|
bool ConvertFactionSpecific = true;
|
||||||
|
bool SyncUnpairedFactionSpecific = false;
|
||||||
|
AccountBound::IdFilter Filter;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CharacterInfo
|
||||||
|
{
|
||||||
|
uint32 Guid = 0;
|
||||||
|
uint32 AccountId = 0;
|
||||||
|
uint8 Race = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
ModuleConfig Config;
|
||||||
|
|
||||||
|
bool CanShareBetweenRaces(uint8 sourceRace, uint8 targetRace)
|
||||||
|
{
|
||||||
|
return !Config.SameFactionOnly || Player::TeamIdForRace(sourceRace) == Player::TeamIdForRace(targetRace);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsRealmFirstAchievement(AchievementEntry const* achievement)
|
||||||
|
{
|
||||||
|
return achievement && (achievement->flags & (ACHIEVEMENT_FLAG_REALM_FIRST_REACH | ACHIEVEMENT_FLAG_REALM_FIRST_KILL));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsAchievementAllowed(AchievementEntry const* achievement)
|
||||||
|
{
|
||||||
|
if (!achievement || (achievement->flags & ACHIEVEMENT_FLAG_COUNTER))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!Config.Filter.Allows(achievement->ID))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!Config.SyncRealmFirst && IsRealmFirstAchievement(achievement))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (!Config.SyncHidden && (achievement->flags & ACHIEVEMENT_FLAG_HIDDEN))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 GetFactionAchievementForTeam(uint32 achievementId, TeamId targetTeam)
|
||||||
|
{
|
||||||
|
AchievementEntry const* achievement = sAchievementStore.LookupEntry(achievementId);
|
||||||
|
if (!IsAchievementAllowed(achievement))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
if (achievement->requiredFaction == ACHIEVEMENT_FACTION_ANY)
|
||||||
|
return achievementId;
|
||||||
|
|
||||||
|
if (achievement->requiredFaction == ACHIEVEMENT_FACTION_ALLIANCE && targetTeam == TEAM_ALLIANCE)
|
||||||
|
return achievementId;
|
||||||
|
|
||||||
|
if (achievement->requiredFaction == ACHIEVEMENT_FACTION_HORDE && targetTeam == TEAM_HORDE)
|
||||||
|
return achievementId;
|
||||||
|
|
||||||
|
if (!Config.ConvertFactionSpecific)
|
||||||
|
return Config.SyncUnpairedFactionSpecific ? achievementId : 0;
|
||||||
|
|
||||||
|
for (auto const& [allianceAchievement, hordeAchievement] : sObjectMgr->FactionChangeAchievements)
|
||||||
|
{
|
||||||
|
uint32 mappedAchievement = 0;
|
||||||
|
|
||||||
|
if (achievementId == allianceAchievement && targetTeam == TEAM_HORDE)
|
||||||
|
mappedAchievement = hordeAchievement;
|
||||||
|
else if (achievementId == hordeAchievement && targetTeam == TEAM_ALLIANCE)
|
||||||
|
mappedAchievement = allianceAchievement;
|
||||||
|
|
||||||
|
if (!mappedAchievement)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
AchievementEntry const* mappedEntry = sAchievementStore.LookupEntry(mappedAchievement);
|
||||||
|
return IsAchievementAllowed(mappedEntry) ? mappedAchievement : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config.SyncUnpairedFactionSpecific ? achievementId : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 GetAchievementForRace(uint32 achievementId, uint8 race)
|
||||||
|
{
|
||||||
|
return GetFactionAchievementForTeam(achievementId, Player::TeamIdForRace(race));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql)
|
||||||
|
{
|
||||||
|
if (!trans)
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
|
||||||
|
trans->Append(sql);
|
||||||
|
|
||||||
|
if (trans->GetSize() >= 1000)
|
||||||
|
{
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommitIfNeeded(CharacterDatabaseTransaction& trans)
|
||||||
|
{
|
||||||
|
if (trans && trans->GetSize())
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InsertAchievementForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 achievementId, uint32 date)
|
||||||
|
{
|
||||||
|
AppendOrCommit(trans, Acore::StringFormat(
|
||||||
|
"INSERT IGNORE INTO character_achievement (guid, achievement, date) VALUES ({}, {}, {})",
|
||||||
|
targetGuid, achievementId, date));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<CharacterInfo> LoadAccountCharacters(uint32 accountId)
|
||||||
|
{
|
||||||
|
std::vector<CharacterInfo> characters;
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT guid, account, race FROM characters WHERE account = {}", accountId);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return characters;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
characters.push_back({
|
||||||
|
fields[0].Get<uint32>(),
|
||||||
|
fields[1].Get<uint32>(),
|
||||||
|
fields[2].Get<uint8>()
|
||||||
|
});
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
return characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncAchievementToAccount(Player* player, uint32 achievementId, uint32 date)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint8 const sourceRace = player->getRace(true);
|
||||||
|
std::vector<CharacterInfo> characters = LoadAccountCharacters(player->GetSession()->GetAccountId());
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint32 synced = 0;
|
||||||
|
|
||||||
|
for (CharacterInfo const& target : characters)
|
||||||
|
{
|
||||||
|
if (target.Guid == player->GetGUID().GetCounter() || !CanShareBetweenRaces(sourceRace, target.Race))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint32 const targetAchievementId = GetAchievementForRace(achievementId, target.Race);
|
||||||
|
if (!targetAchievementId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertAchievementForCharacter(trans, target.Guid, targetAchievementId, date);
|
||||||
|
++synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
if (synced)
|
||||||
|
LOG_DEBUG("module.accountboundachievements",
|
||||||
|
"AccountBoundAchievements: synced achievement {} from player {} to {} account character(s).",
|
||||||
|
achievementId, player->GetGUID().GetCounter(), synced);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillAchievementsForCharacter(Player* player)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 const accountId = player->GetSession()->GetAccountId();
|
||||||
|
uint32 const targetGuid = player->GetGUID().GetCounter();
|
||||||
|
uint8 const targetRace = player->getRace(true);
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT ca.achievement, MIN(ca.date), c.race "
|
||||||
|
"FROM character_achievement ca "
|
||||||
|
"INNER JOIN characters c ON c.guid = ca.guid "
|
||||||
|
"WHERE c.account = {} AND c.guid <> {} "
|
||||||
|
"GROUP BY ca.achievement, c.race "
|
||||||
|
"ORDER BY MIN(ca.date)",
|
||||||
|
accountId, targetGuid);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint32 inserted = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint32 const achievementId = fields[0].Get<uint32>();
|
||||||
|
uint32 const date = fields[1].Get<uint32>();
|
||||||
|
uint8 const sourceRace = fields[2].Get<uint8>();
|
||||||
|
|
||||||
|
if (!CanShareBetweenRaces(sourceRace, targetRace))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint32 const targetAchievementId = GetAchievementForRace(achievementId, targetRace);
|
||||||
|
if (!targetAchievementId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertAchievementForCharacter(trans, targetGuid, targetAchievementId, date);
|
||||||
|
++inserted;
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
if (inserted)
|
||||||
|
LOG_INFO("module.accountboundachievements",
|
||||||
|
"AccountBoundAchievements: seeded {} achievement row(s) for new character {}.",
|
||||||
|
inserted, targetGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillAllAchievements()
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !Config.StartupBackfill)
|
||||||
|
return;
|
||||||
|
|
||||||
|
QueryResult charactersResult = CharacterDatabase.Query(
|
||||||
|
"SELECT guid, account, race FROM characters WHERE account <> 0");
|
||||||
|
|
||||||
|
if (!charactersResult)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::unordered_map<uint32, std::vector<CharacterInfo>> charactersByAccount;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = charactersResult->Fetch();
|
||||||
|
CharacterInfo character = {
|
||||||
|
fields[0].Get<uint32>(),
|
||||||
|
fields[1].Get<uint32>(),
|
||||||
|
fields[2].Get<uint8>()
|
||||||
|
};
|
||||||
|
charactersByAccount[character.AccountId].push_back(character);
|
||||||
|
} while (charactersResult->NextRow());
|
||||||
|
|
||||||
|
QueryResult achievementsResult = CharacterDatabase.Query(
|
||||||
|
"SELECT c.account, c.race, ca.achievement, MIN(ca.date) "
|
||||||
|
"FROM character_achievement ca "
|
||||||
|
"INNER JOIN characters c ON c.guid = ca.guid "
|
||||||
|
"WHERE c.account <> 0 "
|
||||||
|
"GROUP BY c.account, c.race, ca.achievement "
|
||||||
|
"ORDER BY MIN(ca.date)");
|
||||||
|
|
||||||
|
if (!achievementsResult)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint64 inserted = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = achievementsResult->Fetch();
|
||||||
|
uint32 const accountId = fields[0].Get<uint32>();
|
||||||
|
uint8 const sourceRace = fields[1].Get<uint8>();
|
||||||
|
uint32 const achievementId = fields[2].Get<uint32>();
|
||||||
|
uint32 const date = fields[3].Get<uint32>();
|
||||||
|
auto accountItr = charactersByAccount.find(accountId);
|
||||||
|
|
||||||
|
if (accountItr == charactersByAccount.end())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (CharacterInfo const& target : accountItr->second)
|
||||||
|
{
|
||||||
|
if (!CanShareBetweenRaces(sourceRace, target.Race))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint32 const targetAchievementId = GetAchievementForRace(achievementId, target.Race);
|
||||||
|
if (!targetAchievementId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertAchievementForCharacter(trans, target.Guid, targetAchievementId, date);
|
||||||
|
++inserted;
|
||||||
|
}
|
||||||
|
} while (achievementsResult->NextRow());
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
LOG_INFO("module.accountboundachievements",
|
||||||
|
"AccountBoundAchievements: startup backfill queued {} achievement row(s).", inserted);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadModuleConfig()
|
||||||
|
{
|
||||||
|
Config.Enabled = AccountBound::IsCategoryEnabled("Achievements");
|
||||||
|
Config.StartupBackfill = sConfigMgr->GetOption<bool>("AccountBound.Achievements.StartupBackfill", true);
|
||||||
|
Config.SameFactionOnly = sConfigMgr->GetOption<bool>("AccountBound.Achievements.SameFactionOnly", false);
|
||||||
|
Config.SyncOnCreate = sConfigMgr->GetOption<bool>("AccountBound.Achievements.SyncOnCreate", true);
|
||||||
|
Config.SyncRealmFirst = sConfigMgr->GetOption<bool>("AccountBound.Achievements.SyncRealmFirst", false);
|
||||||
|
Config.SyncHidden = sConfigMgr->GetOption<bool>("AccountBound.Achievements.SyncHidden", false);
|
||||||
|
Config.ConvertFactionSpecific = sConfigMgr->GetOption<bool>("AccountBound.Achievements.ConvertFactionSpecific", true);
|
||||||
|
Config.SyncUnpairedFactionSpecific = sConfigMgr->GetOption<bool>("AccountBound.Achievements.SyncUnpairedFactionSpecific", false);
|
||||||
|
Config.Filter = AccountBound::LoadIdFilter("Achievements");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountBoundAchievementsWorldScript : public WorldScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundAchievementsWorldScript() : WorldScript("AccountBoundAchievementsWorldScript", {
|
||||||
|
WORLDHOOK_ON_AFTER_CONFIG_LOAD,
|
||||||
|
WORLDHOOK_ON_STARTUP
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnAfterConfigLoad(bool reload) override
|
||||||
|
{
|
||||||
|
LoadModuleConfig();
|
||||||
|
LOG_INFO("module.accountboundachievements",
|
||||||
|
"AccountBoundAchievements: {}. CreateSync={}, StartupBackfill={}, SameFactionOnly={}, RealmFirst={}.",
|
||||||
|
Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled",
|
||||||
|
Config.Enabled && Config.SyncOnCreate ? "on" : "off",
|
||||||
|
Config.Enabled && Config.StartupBackfill ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SameFactionOnly ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SyncRealmFirst ? "on" : "off");
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnStartup() override
|
||||||
|
{
|
||||||
|
BackfillAllAchievements();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccountBoundAchievementsPlayerScript : public PlayerScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundAchievementsPlayerScript() : PlayerScript("AccountBoundAchievementsPlayerScript", {
|
||||||
|
PLAYERHOOK_ON_CREATE,
|
||||||
|
PLAYERHOOK_ON_ACHI_COMPLETE
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnPlayerCreate(Player* player) override
|
||||||
|
{
|
||||||
|
if (Config.SyncOnCreate)
|
||||||
|
BackfillAchievementsForCharacter(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerAchievementComplete(Player* player, AchievementEntry const* achievement) override
|
||||||
|
{
|
||||||
|
if (achievement)
|
||||||
|
SyncAchievementToAccount(player, achievement->ID, uint32(GameTime::GetGameTime().count()));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void AddAccountBoundAchievementsScripts()
|
||||||
|
{
|
||||||
|
new AccountBoundAchievementsWorldScript();
|
||||||
|
new AccountBoundAchievementsPlayerScript();
|
||||||
|
}
|
||||||
@@ -0,0 +1,526 @@
|
|||||||
|
/*
|
||||||
|
* AccountWideFriends module for AzerothCore.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "AccountBound.h"
|
||||||
|
#include "Config.h"
|
||||||
|
#include "DatabaseEnv.h"
|
||||||
|
#include "Log.h"
|
||||||
|
#include "Player.h"
|
||||||
|
#include "PlayerScript.h"
|
||||||
|
#include "SocialMgr.h"
|
||||||
|
#include "WorldScript.h"
|
||||||
|
#include "WorldSession.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <string>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
constexpr uint32 FriendFlag = SOCIAL_FLAG_FRIEND;
|
||||||
|
constexpr uint32 WithoutFriendFlagMask = 0xFE;
|
||||||
|
|
||||||
|
struct ModuleConfig
|
||||||
|
{
|
||||||
|
bool Enabled = true;
|
||||||
|
bool SyncOnCreate = true;
|
||||||
|
bool SyncOnlineChanges = true;
|
||||||
|
bool StartupBackfill = true;
|
||||||
|
bool SameFactionOnly = false;
|
||||||
|
uint32 SyncIntervalMs = 3000;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CharacterInfo
|
||||||
|
{
|
||||||
|
uint32 Guid = 0;
|
||||||
|
uint8 Race = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
using FriendMap = std::unordered_map<uint32, std::string>;
|
||||||
|
|
||||||
|
ModuleConfig Config;
|
||||||
|
std::unordered_map<uint32, uint32> UpdateTimersByCharacter;
|
||||||
|
std::unordered_map<uint32, FriendMap> CachedFriendsByCharacter;
|
||||||
|
|
||||||
|
bool IsEnabled()
|
||||||
|
{
|
||||||
|
return Config.Enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string EscapeSqlString(std::string text)
|
||||||
|
{
|
||||||
|
CharacterDatabase.EscapeString(text);
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CanShareBetweenRaces(uint8 ownerRace, uint8 friendRace)
|
||||||
|
{
|
||||||
|
return !Config.SameFactionOnly || Player::TeamIdForRace(ownerRace) == Player::TeamIdForRace(friendRace);
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<CharacterInfo> LoadAccountCharacterInfos(uint32 accountId)
|
||||||
|
{
|
||||||
|
std::vector<CharacterInfo> characters;
|
||||||
|
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT guid, race FROM characters WHERE account = {}", accountId);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return characters;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
characters.push_back({ fields[0].Get<uint32>(), fields[1].Get<uint8>() });
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
return characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<uint32> LoadAccountCharacters(uint32 accountId)
|
||||||
|
{
|
||||||
|
std::vector<uint32> characterGuids;
|
||||||
|
|
||||||
|
for (CharacterInfo const& character : LoadAccountCharacterInfos(accountId))
|
||||||
|
characterGuids.push_back(character.Guid);
|
||||||
|
|
||||||
|
return characterGuids;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unordered_set<uint32> MakeGuidSet(std::vector<uint32> const& guids)
|
||||||
|
{
|
||||||
|
return { guids.begin(), guids.end() };
|
||||||
|
}
|
||||||
|
|
||||||
|
std::unordered_set<uint32> MakeGuidSet(std::vector<CharacterInfo> const& characters)
|
||||||
|
{
|
||||||
|
std::unordered_set<uint32> guids;
|
||||||
|
|
||||||
|
for (CharacterInfo const& character : characters)
|
||||||
|
guids.insert(character.Guid);
|
||||||
|
|
||||||
|
return guids;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint8 LoadCharacterRace(uint32 characterGuid)
|
||||||
|
{
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT race FROM characters WHERE guid = {}", characterGuid);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return result->Fetch()[0].Get<uint8>();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CanCharacterShareWithFriend(CharacterInfo const& owner, uint32 friendGuid)
|
||||||
|
{
|
||||||
|
uint8 const friendRace = LoadCharacterRace(friendGuid);
|
||||||
|
return friendRace && CanShareBetweenRaces(owner.Race, friendRace);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveFriendFromCharacter(uint32 ownerGuid, uint32 friendGuid)
|
||||||
|
{
|
||||||
|
CharacterDatabase.DirectExecute(
|
||||||
|
"UPDATE character_social SET flags = flags & {} "
|
||||||
|
"WHERE guid = {} AND friend = {} AND (flags & {}) <> 0",
|
||||||
|
WithoutFriendFlagMask, ownerGuid, friendGuid, FriendFlag);
|
||||||
|
|
||||||
|
CharacterDatabase.DirectExecute(
|
||||||
|
"DELETE FROM character_social WHERE guid = {} AND friend = {} AND flags = 0",
|
||||||
|
ownerGuid, friendGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
FriendMap LoadCharacterFriends(uint32 characterGuid)
|
||||||
|
{
|
||||||
|
FriendMap friends;
|
||||||
|
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT social.friend, COALESCE(social.note, ''), owner.race, friend_char.race "
|
||||||
|
"FROM character_social social "
|
||||||
|
"INNER JOIN characters owner ON owner.guid = social.guid "
|
||||||
|
"INNER JOIN characters friend_char ON friend_char.guid = social.friend "
|
||||||
|
"WHERE social.guid = {} AND owner.account <> friend_char.account AND (social.flags & {}) <> 0",
|
||||||
|
characterGuid, FriendFlag);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return friends;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint32 const friendGuid = fields[0].Get<uint32>();
|
||||||
|
uint8 const ownerRace = fields[2].Get<uint8>();
|
||||||
|
uint8 const friendRace = fields[3].Get<uint8>();
|
||||||
|
|
||||||
|
if (friendGuid && friendGuid != characterGuid && CanShareBetweenRaces(ownerRace, friendRace))
|
||||||
|
friends[friendGuid] = fields[1].Get<std::string>();
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
return friends;
|
||||||
|
}
|
||||||
|
|
||||||
|
FriendMap LoadAccountFriendUnion(uint32 accountId)
|
||||||
|
{
|
||||||
|
FriendMap friends;
|
||||||
|
std::vector<uint32> accountCharacters = LoadAccountCharacters(accountId);
|
||||||
|
std::unordered_set<uint32> ownCharacters = MakeGuidSet(accountCharacters);
|
||||||
|
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT social.friend, COALESCE(social.note, '') "
|
||||||
|
"FROM character_social social "
|
||||||
|
"INNER JOIN characters owner ON owner.guid = social.guid "
|
||||||
|
"WHERE owner.account = {} AND (social.flags & {}) <> 0 "
|
||||||
|
"ORDER BY social.note <> '' DESC",
|
||||||
|
accountId, FriendFlag);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return friends;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint32 const friendGuid = fields[0].Get<uint32>();
|
||||||
|
|
||||||
|
if (!friendGuid || ownCharacters.contains(friendGuid))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
std::string const note = fields[1].Get<std::string>();
|
||||||
|
auto [itr, inserted] = friends.emplace(friendGuid, note);
|
||||||
|
|
||||||
|
if (!inserted && itr->second.empty() && !note.empty())
|
||||||
|
itr->second = note;
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
return friends;
|
||||||
|
}
|
||||||
|
|
||||||
|
void InsertOrUpdateFriendForCharacter(uint32 ownerGuid, uint32 friendGuid, std::string const& note, bool forceNote)
|
||||||
|
{
|
||||||
|
if (!ownerGuid || !friendGuid || ownerGuid == friendGuid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::string safeNote = note.substr(0, 48);
|
||||||
|
safeNote = EscapeSqlString(safeNote);
|
||||||
|
|
||||||
|
if (forceNote)
|
||||||
|
{
|
||||||
|
CharacterDatabase.DirectExecute(
|
||||||
|
"INSERT INTO character_social (guid, friend, flags, note) VALUES ({}, {}, {}, '{}') "
|
||||||
|
"ON DUPLICATE KEY UPDATE flags = flags | {}, note = '{}'",
|
||||||
|
ownerGuid, friendGuid, FriendFlag, safeNote, FriendFlag, safeNote);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CharacterDatabase.DirectExecute(
|
||||||
|
"INSERT INTO character_social (guid, friend, flags, note) VALUES ({}, {}, {}, '{}') "
|
||||||
|
"ON DUPLICATE KEY UPDATE flags = flags | {}, note = IF(VALUES(note) <> '', VALUES(note), note)",
|
||||||
|
ownerGuid, friendGuid, FriendFlag, safeNote, FriendFlag);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 SyncFriendToCharacters(
|
||||||
|
std::vector<CharacterInfo> const& accountCharacters,
|
||||||
|
std::unordered_set<uint32> const& ownCharacters,
|
||||||
|
uint32 friendGuid,
|
||||||
|
std::string const& note,
|
||||||
|
bool forceNote)
|
||||||
|
{
|
||||||
|
if (!friendGuid || ownCharacters.contains(friendGuid))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
uint32 synced = 0;
|
||||||
|
|
||||||
|
for (CharacterInfo const& owner : accountCharacters)
|
||||||
|
{
|
||||||
|
if (owner.Guid == friendGuid || !CanCharacterShareWithFriend(owner, friendGuid))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertOrUpdateFriendForCharacter(owner.Guid, friendGuid, note, forceNote);
|
||||||
|
++synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
return synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 SyncFriendToAccount(uint32 accountId, uint32 friendGuid, std::string const& note, bool forceNote = false)
|
||||||
|
{
|
||||||
|
std::vector<CharacterInfo> accountCharacters = LoadAccountCharacterInfos(accountId);
|
||||||
|
std::unordered_set<uint32> ownCharacters = MakeGuidSet(accountCharacters);
|
||||||
|
|
||||||
|
return SyncFriendToCharacters(accountCharacters, ownCharacters, friendGuid, note, forceNote);
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveInvalidFactionFriends(uint32 accountId)
|
||||||
|
{
|
||||||
|
if (!Config.SameFactionOnly || !accountId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT social.guid, social.friend, owner.race, friend_char.race "
|
||||||
|
"FROM character_social social "
|
||||||
|
"INNER JOIN characters owner ON owner.guid = social.guid "
|
||||||
|
"INNER JOIN characters friend_char ON friend_char.guid = social.friend "
|
||||||
|
"WHERE owner.account = {} AND owner.account <> friend_char.account AND (social.flags & {}) <> 0",
|
||||||
|
accountId, FriendFlag);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint32 const ownerGuid = fields[0].Get<uint32>();
|
||||||
|
uint32 const friendGuid = fields[1].Get<uint32>();
|
||||||
|
uint8 const ownerRace = fields[2].Get<uint8>();
|
||||||
|
uint8 const friendRace = fields[3].Get<uint8>();
|
||||||
|
|
||||||
|
if (!CanShareBetweenRaces(ownerRace, friendRace))
|
||||||
|
RemoveFriendFromCharacter(ownerGuid, friendGuid);
|
||||||
|
} while (result->NextRow());
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveOwnAccountFriends(uint32 accountId)
|
||||||
|
{
|
||||||
|
if (!accountId)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabase.DirectExecute(
|
||||||
|
"UPDATE character_social social "
|
||||||
|
"INNER JOIN characters owner ON owner.guid = social.guid "
|
||||||
|
"INNER JOIN characters friend_char ON friend_char.guid = social.friend "
|
||||||
|
"SET social.flags = social.flags & {} "
|
||||||
|
"WHERE owner.account = {} AND friend_char.account = {} AND (social.flags & {}) <> 0",
|
||||||
|
WithoutFriendFlagMask, accountId, accountId, FriendFlag);
|
||||||
|
|
||||||
|
CharacterDatabase.DirectExecute(
|
||||||
|
"DELETE social FROM character_social social "
|
||||||
|
"INNER JOIN characters owner ON owner.guid = social.guid "
|
||||||
|
"INNER JOIN characters friend_char ON friend_char.guid = social.friend "
|
||||||
|
"WHERE owner.account = {} AND friend_char.account = {} AND social.flags = 0",
|
||||||
|
accountId, accountId);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 SyncAccountFriends(uint32 accountId)
|
||||||
|
{
|
||||||
|
RemoveOwnAccountFriends(accountId);
|
||||||
|
RemoveInvalidFactionFriends(accountId);
|
||||||
|
|
||||||
|
std::vector<CharacterInfo> accountCharacters = LoadAccountCharacterInfos(accountId);
|
||||||
|
|
||||||
|
if (accountCharacters.empty())
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
std::unordered_set<uint32> ownCharacters = MakeGuidSet(accountCharacters);
|
||||||
|
FriendMap accountFriends = LoadAccountFriendUnion(accountId);
|
||||||
|
uint32 synced = 0;
|
||||||
|
|
||||||
|
for (auto const& [friendGuid, note] : accountFriends)
|
||||||
|
synced += SyncFriendToCharacters(accountCharacters, ownCharacters, friendGuid, note, false);
|
||||||
|
|
||||||
|
return synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
void RemoveFriendFromAccount(uint32 accountId, uint32 friendGuid)
|
||||||
|
{
|
||||||
|
if (!accountId || !friendGuid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabase.DirectExecute(
|
||||||
|
"UPDATE character_social social "
|
||||||
|
"INNER JOIN characters owner ON owner.guid = social.guid "
|
||||||
|
"SET social.flags = social.flags & {} "
|
||||||
|
"WHERE owner.account = {} AND social.friend = {} AND (social.flags & {}) <> 0",
|
||||||
|
WithoutFriendFlagMask, accountId, friendGuid, FriendFlag);
|
||||||
|
|
||||||
|
CharacterDatabase.DirectExecute(
|
||||||
|
"DELETE social FROM character_social social "
|
||||||
|
"INNER JOIN characters owner ON owner.guid = social.guid "
|
||||||
|
"WHERE owner.account = {} AND social.friend = {} AND social.flags = 0",
|
||||||
|
accountId, friendGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CacheCharacterFriends(uint32 characterGuid)
|
||||||
|
{
|
||||||
|
CachedFriendsByCharacter[characterGuid] = LoadCharacterFriends(characterGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void DetectAndSyncOnlineChanges(Player* player)
|
||||||
|
{
|
||||||
|
if (!player || !player->GetSession())
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 const accountId = player->GetSession()->GetAccountId();
|
||||||
|
uint32 const characterGuid = player->GetGUID().GetCounter();
|
||||||
|
RemoveOwnAccountFriends(accountId);
|
||||||
|
RemoveInvalidFactionFriends(accountId);
|
||||||
|
FriendMap currentFriends = LoadCharacterFriends(characterGuid);
|
||||||
|
|
||||||
|
auto cacheItr = CachedFriendsByCharacter.find(characterGuid);
|
||||||
|
if (cacheItr == CachedFriendsByCharacter.end())
|
||||||
|
{
|
||||||
|
CachedFriendsByCharacter[characterGuid] = std::move(currentFriends);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
FriendMap const& cachedFriends = cacheItr->second;
|
||||||
|
bool changed = false;
|
||||||
|
|
||||||
|
for (auto const& [friendGuid, note] : currentFriends)
|
||||||
|
{
|
||||||
|
auto cachedFriend = cachedFriends.find(friendGuid);
|
||||||
|
|
||||||
|
if (cachedFriend == cachedFriends.end())
|
||||||
|
{
|
||||||
|
SyncFriendToAccount(accountId, friendGuid, note);
|
||||||
|
changed = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (cachedFriend->second != note)
|
||||||
|
{
|
||||||
|
SyncFriendToAccount(accountId, friendGuid, note, true);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto const& cachedFriendEntry : cachedFriends)
|
||||||
|
{
|
||||||
|
uint32 const friendGuid = cachedFriendEntry.first;
|
||||||
|
|
||||||
|
if (!currentFriends.contains(friendGuid))
|
||||||
|
{
|
||||||
|
RemoveFriendFromAccount(accountId, friendGuid);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed)
|
||||||
|
LOG_DEBUG("module.accountwidefriends", "AccountWideFriends: synced friend changes for account {} from character {}.", accountId, characterGuid);
|
||||||
|
|
||||||
|
CacheCharacterFriends(characterGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillAllAccounts()
|
||||||
|
{
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT DISTINCT account FROM characters WHERE account <> 0");
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 accountCount = 0;
|
||||||
|
uint32 syncCount = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint32 const accountId = fields[0].Get<uint32>();
|
||||||
|
|
||||||
|
syncCount += SyncAccountFriends(accountId);
|
||||||
|
++accountCount;
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
LOG_INFO("module.accountwidefriends", "AccountWideFriends: startup backfill checked {} account(s) and queued {} friend row sync operation(s).", accountCount, syncCount);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadModuleConfig()
|
||||||
|
{
|
||||||
|
Config.Enabled = AccountBound::IsCategoryEnabled("Friends");
|
||||||
|
Config.SyncOnCreate = sConfigMgr->GetOption<bool>("AccountBound.Friends.SyncOnCreate", true);
|
||||||
|
Config.SyncOnlineChanges = sConfigMgr->GetOption<bool>("AccountBound.Friends.SyncOnlineChanges", true);
|
||||||
|
Config.StartupBackfill = sConfigMgr->GetOption<bool>("AccountBound.Friends.StartupBackfill", true);
|
||||||
|
Config.SameFactionOnly = sConfigMgr->GetOption<bool>("AccountBound.Friends.SameFactionOnly", false);
|
||||||
|
Config.SyncIntervalMs = std::max<uint32>(
|
||||||
|
1000,
|
||||||
|
sConfigMgr->GetOption<uint32>("AccountBound.Friends.SyncIntervalSeconds", 3) * 1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountWideFriendsWorldScript : public WorldScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountWideFriendsWorldScript() : WorldScript("AccountWideFriendsWorldScript", {
|
||||||
|
WORLDHOOK_ON_AFTER_CONFIG_LOAD,
|
||||||
|
WORLDHOOK_ON_STARTUP
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnAfterConfigLoad(bool reload) override
|
||||||
|
{
|
||||||
|
LoadModuleConfig();
|
||||||
|
|
||||||
|
LOG_INFO("module.accountwidefriends", "AccountWideFriends: {}. Enabled={}, CreateSync={}, OnlineSync={}, SameFactionOnly={}, IntervalMs={}, StartupBackfill={}.",
|
||||||
|
reload ? "configuration reloaded" : "configuration loaded",
|
||||||
|
Config.Enabled ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SyncOnCreate ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SyncOnlineChanges ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SameFactionOnly ? "on" : "off",
|
||||||
|
Config.SyncIntervalMs,
|
||||||
|
Config.Enabled && Config.StartupBackfill ? "on" : "off");
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnStartup() override
|
||||||
|
{
|
||||||
|
if (IsEnabled() && Config.StartupBackfill)
|
||||||
|
BackfillAllAccounts();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccountWideFriendsPlayerScript : public PlayerScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountWideFriendsPlayerScript() : PlayerScript("AccountWideFriendsPlayerScript", {
|
||||||
|
PLAYERHOOK_ON_LOGIN,
|
||||||
|
PLAYERHOOK_ON_CREATE,
|
||||||
|
PLAYERHOOK_ON_LOGOUT,
|
||||||
|
PLAYERHOOK_ON_UPDATE
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnPlayerLogin(Player* player) override
|
||||||
|
{
|
||||||
|
if (!IsEnabled() || !player || !player->GetSession())
|
||||||
|
return;
|
||||||
|
|
||||||
|
CacheCharacterFriends(player->GetGUID().GetCounter());
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerCreate(Player* player) override
|
||||||
|
{
|
||||||
|
if (IsEnabled() && Config.SyncOnCreate && player && player->GetSession())
|
||||||
|
SyncAccountFriends(player->GetSession()->GetAccountId());
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerLogout(Player* player) override
|
||||||
|
{
|
||||||
|
if (!player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 const characterGuid = player->GetGUID().GetCounter();
|
||||||
|
UpdateTimersByCharacter.erase(characterGuid);
|
||||||
|
CachedFriendsByCharacter.erase(characterGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerUpdate(Player* player, uint32 diff) override
|
||||||
|
{
|
||||||
|
if (!IsEnabled() || !Config.SyncOnlineChanges || !player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 const characterGuid = player->GetGUID().GetCounter();
|
||||||
|
uint32& timer = UpdateTimersByCharacter[characterGuid];
|
||||||
|
timer = std::min<uint32>(Config.SyncIntervalMs, timer + diff);
|
||||||
|
|
||||||
|
if (timer < Config.SyncIntervalMs)
|
||||||
|
return;
|
||||||
|
|
||||||
|
timer = 0;
|
||||||
|
DetectAndSyncOnlineChanges(player);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void AddAccountWideFriendsScripts()
|
||||||
|
{
|
||||||
|
new AccountWideFriendsWorldScript();
|
||||||
|
new AccountWideFriendsPlayerScript();
|
||||||
|
}
|
||||||
@@ -0,0 +1,692 @@
|
|||||||
|
/*
|
||||||
|
* Account-bound mounts for AzerothCore.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "AccountBound.h"
|
||||||
|
#include "Config.h"
|
||||||
|
#include "DatabaseEnv.h"
|
||||||
|
#include "DBCEnums.h"
|
||||||
|
#include "ItemTemplate.h"
|
||||||
|
#include "Log.h"
|
||||||
|
#include "ObjectMgr.h"
|
||||||
|
#include "Player.h"
|
||||||
|
#include "PlayerScript.h"
|
||||||
|
#include "RaceMgr.h"
|
||||||
|
#include "SpellAuraDefines.h"
|
||||||
|
#include "SpellInfo.h"
|
||||||
|
#include "SpellMgr.h"
|
||||||
|
#include "StringFormat.h"
|
||||||
|
#include "WorldScript.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct ModuleConfig
|
||||||
|
{
|
||||||
|
bool Enabled = true;
|
||||||
|
bool StartupBackfill = true;
|
||||||
|
bool SyncOnCreate = true;
|
||||||
|
bool ConvertFactionSpecific = false;
|
||||||
|
bool RespectFactionRestrictions = true;
|
||||||
|
bool RequireRiding = true;
|
||||||
|
bool SyncClassMounts = true;
|
||||||
|
bool RequireClass = true;
|
||||||
|
bool ClassMountsSameFactionOnly = true;
|
||||||
|
bool CleanupInvalid = true;
|
||||||
|
bool BackfillOnRidingSkillChange = true;
|
||||||
|
AccountBound::IdFilter Filter;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CharacterInfo
|
||||||
|
{
|
||||||
|
uint32 Guid = 0;
|
||||||
|
uint32 AccountId = 0;
|
||||||
|
uint8 Race = 0;
|
||||||
|
uint8 Class = 0;
|
||||||
|
uint16 RidingSkill = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct MountSpellRequirements
|
||||||
|
{
|
||||||
|
uint16 RequiredRidingRank = 1;
|
||||||
|
uint32 ClassMask = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
ModuleConfig Config;
|
||||||
|
std::unordered_map<uint32, TeamId> MountSpellFactionMap;
|
||||||
|
std::unordered_map<uint32, MountSpellRequirements> MountSpellRequirementsMap;
|
||||||
|
|
||||||
|
bool IsMountSpell(uint32 spellId)
|
||||||
|
{
|
||||||
|
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId);
|
||||||
|
return spellInfo && spellInfo->HasAura(SPELL_AURA_MOUNTED);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 GetClassMaskForClass(uint8 classId)
|
||||||
|
{
|
||||||
|
if (!classId || classId >= MAX_CLASSES)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
return 1u << (classId - 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 NormalizeClassMask(uint32 classMask)
|
||||||
|
{
|
||||||
|
classMask &= CLASSMASK_ALL_PLAYABLE;
|
||||||
|
return classMask && classMask != CLASSMASK_ALL_PLAYABLE ? classMask : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint16 GetRidingRankFromRequiredSpell(uint32 spellId)
|
||||||
|
{
|
||||||
|
switch (spellId)
|
||||||
|
{
|
||||||
|
case 33388: return 75; // Apprentice Riding
|
||||||
|
case 33391: return 150; // Journeyman Riding
|
||||||
|
case 34090: return 225; // Expert Riding
|
||||||
|
case 34091: return 300; // Artisan Riding
|
||||||
|
default: return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
MountSpellRequirements& GetMountSpellRequirements(uint32 spellId)
|
||||||
|
{
|
||||||
|
return MountSpellRequirementsMap.try_emplace(spellId).first->second;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetMountRidingRequirement(uint32 spellId, uint16 requiredRidingRank)
|
||||||
|
{
|
||||||
|
if (!IsMountSpell(spellId) || !requiredRidingRank)
|
||||||
|
return;
|
||||||
|
|
||||||
|
MountSpellRequirements& requirements = GetMountSpellRequirements(spellId);
|
||||||
|
requirements.RequiredRidingRank = std::max(requirements.RequiredRidingRank, requiredRidingRank);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetMountClassRestriction(uint32 spellId, uint32 classMask)
|
||||||
|
{
|
||||||
|
classMask = NormalizeClassMask(classMask);
|
||||||
|
if (!IsMountSpell(spellId) || !classMask)
|
||||||
|
return;
|
||||||
|
|
||||||
|
GetMountSpellRequirements(spellId).ClassMask |= classMask;
|
||||||
|
}
|
||||||
|
|
||||||
|
void ApplyClassMountFallbacks()
|
||||||
|
{
|
||||||
|
uint32 const paladinMask = GetClassMaskForClass(CLASS_PALADIN);
|
||||||
|
uint32 const warlockMask = GetClassMaskForClass(CLASS_WARLOCK);
|
||||||
|
uint32 const deathKnightMask = GetClassMaskForClass(CLASS_DEATH_KNIGHT);
|
||||||
|
uint32 const druidMask = GetClassMaskForClass(CLASS_DRUID);
|
||||||
|
|
||||||
|
for (uint32 spellId : { 13819u, 23214u, 34767u, 34769u, 66906u })
|
||||||
|
SetMountClassRestriction(spellId, paladinMask);
|
||||||
|
|
||||||
|
for (uint32 spellId : { 5784u, 23161u })
|
||||||
|
SetMountClassRestriction(spellId, warlockMask);
|
||||||
|
|
||||||
|
for (uint32 spellId : { 48778u, 54729u })
|
||||||
|
SetMountClassRestriction(spellId, deathKnightMask);
|
||||||
|
|
||||||
|
for (uint32 spellId : { 33943u, 40120u, 40121u })
|
||||||
|
SetMountClassRestriction(spellId, druidMask);
|
||||||
|
}
|
||||||
|
|
||||||
|
TeamId GetAllowedMountTeamForRaceMask(uint32 allowableRace)
|
||||||
|
{
|
||||||
|
uint32 const playableRaceMask = sRaceMgr->GetPlayableRaceMask();
|
||||||
|
uint32 const allowedRaceMask = allowableRace ? (allowableRace & playableRaceMask) : playableRaceMask;
|
||||||
|
|
||||||
|
if (!allowedRaceMask)
|
||||||
|
return TEAM_NEUTRAL;
|
||||||
|
|
||||||
|
bool const hasAllianceRace = (allowedRaceMask & sRaceMgr->GetAllianceRaceMask()) != 0;
|
||||||
|
bool const hasHordeRace = (allowedRaceMask & sRaceMgr->GetHordeRaceMask()) != 0;
|
||||||
|
|
||||||
|
if (hasAllianceRace && !hasHordeRace)
|
||||||
|
return TEAM_ALLIANCE;
|
||||||
|
|
||||||
|
if (hasHordeRace && !hasAllianceRace)
|
||||||
|
return TEAM_HORDE;
|
||||||
|
|
||||||
|
return TEAM_NEUTRAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SetMountSpellFaction(uint32 spellId, TeamId teamId, bool force = false)
|
||||||
|
{
|
||||||
|
if (!IsMountSpell(spellId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
auto itr = MountSpellFactionMap.find(spellId);
|
||||||
|
if (itr == MountSpellFactionMap.end())
|
||||||
|
{
|
||||||
|
MountSpellFactionMap.emplace(spellId, teamId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (force)
|
||||||
|
{
|
||||||
|
itr->second = teamId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (teamId == TEAM_NEUTRAL)
|
||||||
|
{
|
||||||
|
itr->second = TEAM_NEUTRAL;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itr->second == TEAM_NEUTRAL)
|
||||||
|
{
|
||||||
|
itr->second = teamId;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (itr->second != teamId)
|
||||||
|
itr->second = TEAM_NEUTRAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildMountCache()
|
||||||
|
{
|
||||||
|
MountSpellFactionMap.clear();
|
||||||
|
MountSpellRequirementsMap.clear();
|
||||||
|
|
||||||
|
for (uint32 spellId = 1; spellId < sSpellMgr->GetSpellInfoStoreSize(); ++spellId)
|
||||||
|
{
|
||||||
|
if (!IsMountSpell(spellId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
MountSpellFactionMap.emplace(spellId, TEAM_NEUTRAL);
|
||||||
|
MountSpellRequirementsMap.emplace(spellId, MountSpellRequirements{});
|
||||||
|
|
||||||
|
SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId);
|
||||||
|
for (SkillLineAbilityMap::const_iterator itr = bounds.first; itr != bounds.second; ++itr)
|
||||||
|
{
|
||||||
|
SkillLineAbilityEntry const* skillLineAbility = itr->second;
|
||||||
|
if (!skillLineAbility)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
SetMountClassRestriction(spellId, skillLineAbility->ClassMask);
|
||||||
|
|
||||||
|
if (skillLineAbility->SkillLine == SKILL_RIDING)
|
||||||
|
SetMountRidingRequirement(spellId, uint16(skillLineAbility->MinSkillLineRank));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ItemTemplateContainer const* itemTemplates = sObjectMgr->GetItemTemplateStore())
|
||||||
|
{
|
||||||
|
for (auto const& itemTemplatePair : *itemTemplates)
|
||||||
|
{
|
||||||
|
ItemTemplate const& itemTemplate = itemTemplatePair.second;
|
||||||
|
if (itemTemplate.Class != ITEM_CLASS_MISC || itemTemplate.SubClass != ITEM_SUBCLASS_JUNK_MOUNT)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
TeamId const itemTeam = GetAllowedMountTeamForRaceMask(itemTemplate.AllowableRace);
|
||||||
|
|
||||||
|
for (uint8 i = 0; i < MAX_ITEM_PROTO_SPELLS; ++i)
|
||||||
|
{
|
||||||
|
int32 const itemSpellId = itemTemplate.Spells[i].SpellId;
|
||||||
|
if (itemSpellId <= 0 || !IsMountSpell(uint32(itemSpellId)))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
SetMountSpellFaction(uint32(itemSpellId), itemTeam);
|
||||||
|
SetMountClassRestriction(uint32(itemSpellId), itemTemplate.AllowableClass);
|
||||||
|
|
||||||
|
if (itemTemplate.RequiredSkill == SKILL_RIDING)
|
||||||
|
SetMountRidingRequirement(uint32(itemSpellId), uint16(itemTemplate.RequiredSkillRank));
|
||||||
|
|
||||||
|
SetMountRidingRequirement(uint32(itemSpellId), GetRidingRankFromRequiredSpell(itemTemplate.RequiredSpell));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (auto const& [allianceSpell, hordeSpell] : sObjectMgr->FactionChangeSpells)
|
||||||
|
{
|
||||||
|
if (!IsMountSpell(allianceSpell) || !IsMountSpell(hordeSpell))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
SetMountSpellFaction(allianceSpell, TEAM_ALLIANCE, true);
|
||||||
|
SetMountSpellFaction(hordeSpell, TEAM_HORDE, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
ApplyClassMountFallbacks();
|
||||||
|
LOG_INFO("module.accountboundmounts",
|
||||||
|
"AccountBoundMounts: cached {} faction rule(s) and {} requirement rule(s).",
|
||||||
|
MountSpellFactionMap.size(), MountSpellRequirementsMap.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
TeamId GetMountSpellFaction(uint32 spellId)
|
||||||
|
{
|
||||||
|
auto itr = MountSpellFactionMap.find(spellId);
|
||||||
|
return itr != MountSpellFactionMap.end() ? itr->second : TEAM_NEUTRAL;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 GetFactionConvertedMountSpell(uint32 spellId, TeamId targetTeam)
|
||||||
|
{
|
||||||
|
for (auto const& [allianceSpell, hordeSpell] : sObjectMgr->FactionChangeSpells)
|
||||||
|
{
|
||||||
|
if (!IsMountSpell(allianceSpell) || !IsMountSpell(hordeSpell))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (spellId == allianceSpell && targetTeam == TEAM_HORDE)
|
||||||
|
return hordeSpell;
|
||||||
|
|
||||||
|
if (spellId == hordeSpell && targetTeam == TEAM_ALLIANCE)
|
||||||
|
return allianceSpell;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsMountSpellAllowedForTeam(uint32 spellId, TeamId targetTeam)
|
||||||
|
{
|
||||||
|
if (!Config.RespectFactionRestrictions)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
TeamId const mountTeam = GetMountSpellFaction(spellId);
|
||||||
|
return mountTeam == TEAM_NEUTRAL || mountTeam == targetTeam;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 GetMountSpellForRace(uint32 spellId, uint8 race)
|
||||||
|
{
|
||||||
|
if (!Config.Filter.Allows(spellId) || !IsMountSpell(spellId))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
TeamId const targetTeam = Player::TeamIdForRace(race);
|
||||||
|
if (IsMountSpellAllowedForTeam(spellId, targetTeam))
|
||||||
|
return spellId;
|
||||||
|
|
||||||
|
if (!Config.ConvertFactionSpecific)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
uint32 const convertedSpellId = GetFactionConvertedMountSpell(spellId, targetTeam);
|
||||||
|
return Config.Filter.Allows(convertedSpellId) &&
|
||||||
|
IsMountSpellAllowedForTeam(convertedSpellId, targetTeam) ? convertedSpellId : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsClassRestrictedMount(uint32 spellId)
|
||||||
|
{
|
||||||
|
return GetMountSpellRequirements(spellId).ClassMask != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ShouldAccountSyncMount(uint32 spellId)
|
||||||
|
{
|
||||||
|
return Config.Filter.Allows(spellId) && IsMountSpell(spellId) &&
|
||||||
|
(Config.SyncClassMounts || !IsClassRestrictedMount(spellId));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CanShareClassMountFromRace(uint32 spellId, uint8 sourceRace, uint8 targetRace)
|
||||||
|
{
|
||||||
|
if (!Config.ClassMountsSameFactionOnly || !sourceRace || !IsClassRestrictedMount(spellId))
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return Player::TeamIdForRace(sourceRace) == Player::TeamIdForRace(targetRace);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CanCharacterReceiveMount(uint32 spellId, uint8 classId, uint16 ridingSkill, uint8 sourceRace = 0, uint8 targetRace = 0)
|
||||||
|
{
|
||||||
|
MountSpellRequirements const& requirements = GetMountSpellRequirements(spellId);
|
||||||
|
|
||||||
|
if (Config.RequireRiding && ridingSkill < requirements.RequiredRidingRank)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
if (Config.RequireClass && requirements.ClassMask && !(requirements.ClassMask & GetClassMaskForClass(classId)))
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return !targetRace || CanShareClassMountFromRace(spellId, sourceRace, targetRace);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CanCharacterReceiveMount(uint32 spellId, CharacterInfo const& character, uint8 sourceRace = 0)
|
||||||
|
{
|
||||||
|
return CanCharacterReceiveMount(spellId, character.Class, character.RidingSkill, sourceRace, character.Race);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CanPlayerReceiveMount(uint32 spellId, Player* player, uint8 sourceRace = 0)
|
||||||
|
{
|
||||||
|
return player && CanCharacterReceiveMount(
|
||||||
|
spellId, player->getClass(), player->GetSkillValue(SKILL_RIDING), sourceRace, player->getRace(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql)
|
||||||
|
{
|
||||||
|
if (!trans)
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
|
||||||
|
trans->Append(sql);
|
||||||
|
|
||||||
|
if (trans->GetSize() >= 1000)
|
||||||
|
{
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommitIfNeeded(CharacterDatabaseTransaction& trans)
|
||||||
|
{
|
||||||
|
if (trans && trans->GetSize())
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InsertMountForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 spellId)
|
||||||
|
{
|
||||||
|
AppendOrCommit(trans, Acore::StringFormat(
|
||||||
|
"INSERT IGNORE INTO character_spell (guid, spell, specMask) VALUES ({}, {}, {})",
|
||||||
|
targetGuid, spellId, uint32(SPEC_MASK_ALL)));
|
||||||
|
}
|
||||||
|
|
||||||
|
void DeleteMountForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 spellId)
|
||||||
|
{
|
||||||
|
AppendOrCommit(trans, Acore::StringFormat(
|
||||||
|
"DELETE FROM character_spell WHERE guid = {} AND spell = {}",
|
||||||
|
targetGuid, spellId));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<CharacterInfo> LoadAccountCharacters(uint32 accountId)
|
||||||
|
{
|
||||||
|
std::vector<CharacterInfo> characters;
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT c.guid, c.account, c.race, c.`class`, COALESCE(cs.value, 0) "
|
||||||
|
"FROM characters c "
|
||||||
|
"LEFT JOIN character_skills cs ON cs.guid = c.guid AND cs.skill = {} "
|
||||||
|
"WHERE c.account = {}",
|
||||||
|
uint32(SKILL_RIDING), accountId);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return characters;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
characters.push_back({
|
||||||
|
fields[0].Get<uint32>(),
|
||||||
|
fields[1].Get<uint32>(),
|
||||||
|
fields[2].Get<uint8>(),
|
||||||
|
fields[3].Get<uint8>(),
|
||||||
|
fields[4].Get<uint16>()
|
||||||
|
});
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
return characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncMountToAccount(Player* player, uint32 spellId)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !player || !ShouldAccountSyncMount(spellId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint8 const sourceRace = player->getRace(true);
|
||||||
|
std::vector<CharacterInfo> characters = LoadAccountCharacters(player->GetSession()->GetAccountId());
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint32 synced = 0;
|
||||||
|
|
||||||
|
for (CharacterInfo const& target : characters)
|
||||||
|
{
|
||||||
|
if (target.Guid == player->GetGUID().GetCounter())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint32 const targetSpellId = GetMountSpellForRace(spellId, target.Race);
|
||||||
|
if (!targetSpellId || !CanCharacterReceiveMount(targetSpellId, target, sourceRace))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertMountForCharacter(trans, target.Guid, targetSpellId);
|
||||||
|
++synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
if (synced)
|
||||||
|
LOG_DEBUG("module.accountboundmounts",
|
||||||
|
"AccountBoundMounts: synced mount spell {} from player {} to {} account character(s).",
|
||||||
|
spellId, player->GetGUID().GetCounter(), synced);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillMountsForCharacter(Player* player)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 const accountId = player->GetSession()->GetAccountId();
|
||||||
|
uint32 const targetGuid = player->GetGUID().GetCounter();
|
||||||
|
uint8 const targetRace = player->getRace(true);
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT DISTINCT cs.spell, c.race "
|
||||||
|
"FROM character_spell cs "
|
||||||
|
"INNER JOIN characters c ON c.guid = cs.guid "
|
||||||
|
"WHERE c.account = {} AND c.guid <> {}",
|
||||||
|
accountId, targetGuid);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint32 inserted = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint32 const spellId = fields[0].Get<uint32>();
|
||||||
|
uint8 const sourceRace = fields[1].Get<uint8>();
|
||||||
|
|
||||||
|
if (!ShouldAccountSyncMount(spellId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint32 const targetSpellId = GetMountSpellForRace(spellId, targetRace);
|
||||||
|
if (!targetSpellId || !CanPlayerReceiveMount(targetSpellId, player, sourceRace))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertMountForCharacter(trans, targetGuid, targetSpellId);
|
||||||
|
++inserted;
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
if (inserted)
|
||||||
|
LOG_INFO("module.accountboundmounts",
|
||||||
|
"AccountBoundMounts: seeded {} mount spell row(s) for character {}.",
|
||||||
|
inserted, targetGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillAllMounts()
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !Config.StartupBackfill)
|
||||||
|
return;
|
||||||
|
|
||||||
|
QueryResult charactersResult = CharacterDatabase.Query(
|
||||||
|
"SELECT c.guid, c.account, c.race, c.`class`, COALESCE(cs.value, 0) "
|
||||||
|
"FROM characters c "
|
||||||
|
"LEFT JOIN character_skills cs ON cs.guid = c.guid AND cs.skill = {} "
|
||||||
|
"WHERE c.account <> 0",
|
||||||
|
uint32(SKILL_RIDING));
|
||||||
|
|
||||||
|
if (!charactersResult)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::unordered_map<uint32, std::vector<CharacterInfo>> charactersByAccount;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = charactersResult->Fetch();
|
||||||
|
CharacterInfo character = {
|
||||||
|
fields[0].Get<uint32>(),
|
||||||
|
fields[1].Get<uint32>(),
|
||||||
|
fields[2].Get<uint8>(),
|
||||||
|
fields[3].Get<uint8>(),
|
||||||
|
fields[4].Get<uint16>()
|
||||||
|
};
|
||||||
|
charactersByAccount[character.AccountId].push_back(character);
|
||||||
|
} while (charactersResult->NextRow());
|
||||||
|
|
||||||
|
QueryResult spellsResult = CharacterDatabase.Query(
|
||||||
|
"SELECT DISTINCT c.account, c.race, cs.spell "
|
||||||
|
"FROM character_spell cs "
|
||||||
|
"INNER JOIN characters c ON c.guid = cs.guid "
|
||||||
|
"WHERE c.account <> 0");
|
||||||
|
|
||||||
|
if (!spellsResult)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint64 inserted = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = spellsResult->Fetch();
|
||||||
|
uint32 const accountId = fields[0].Get<uint32>();
|
||||||
|
uint8 const sourceRace = fields[1].Get<uint8>();
|
||||||
|
uint32 const spellId = fields[2].Get<uint32>();
|
||||||
|
|
||||||
|
if (!ShouldAccountSyncMount(spellId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
auto accountItr = charactersByAccount.find(accountId);
|
||||||
|
if (accountItr == charactersByAccount.end())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (CharacterInfo const& target : accountItr->second)
|
||||||
|
{
|
||||||
|
uint32 const targetSpellId = GetMountSpellForRace(spellId, target.Race);
|
||||||
|
if (!targetSpellId || !CanCharacterReceiveMount(targetSpellId, target, sourceRace))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertMountForCharacter(trans, target.Guid, targetSpellId);
|
||||||
|
++inserted;
|
||||||
|
}
|
||||||
|
} while (spellsResult->NextRow());
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
LOG_INFO("module.accountboundmounts",
|
||||||
|
"AccountBoundMounts: startup backfill queued {} mount spell row(s).", inserted);
|
||||||
|
}
|
||||||
|
|
||||||
|
void CleanupInvalidMounts()
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !Config.CleanupInvalid)
|
||||||
|
return;
|
||||||
|
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT c.guid, c.race, c.`class`, COALESCE(sk.value, 0), cs.spell "
|
||||||
|
"FROM character_spell cs "
|
||||||
|
"INNER JOIN characters c ON c.guid = cs.guid "
|
||||||
|
"LEFT JOIN character_skills sk ON sk.guid = c.guid AND sk.skill = {}",
|
||||||
|
uint32(SKILL_RIDING));
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint64 removed = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
CharacterInfo character = {
|
||||||
|
fields[0].Get<uint32>(),
|
||||||
|
0,
|
||||||
|
fields[1].Get<uint8>(),
|
||||||
|
fields[2].Get<uint8>(),
|
||||||
|
fields[3].Get<uint16>()
|
||||||
|
};
|
||||||
|
uint32 const spellId = fields[4].Get<uint32>();
|
||||||
|
|
||||||
|
if (!ShouldAccountSyncMount(spellId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint32 const allowedSpellId = GetMountSpellForRace(spellId, character.Race);
|
||||||
|
if (allowedSpellId == spellId && CanCharacterReceiveMount(spellId, character))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
DeleteMountForCharacter(trans, character.Guid, spellId);
|
||||||
|
++removed;
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
if (removed)
|
||||||
|
LOG_INFO("module.accountboundmounts",
|
||||||
|
"AccountBoundMounts: removed {} invalid mount spell row(s).", removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadModuleConfig()
|
||||||
|
{
|
||||||
|
Config.Enabled = AccountBound::IsCategoryEnabled("Mounts");
|
||||||
|
Config.StartupBackfill = sConfigMgr->GetOption<bool>("AccountBound.Mounts.StartupBackfill", true);
|
||||||
|
Config.SyncOnCreate = sConfigMgr->GetOption<bool>("AccountBound.Mounts.SyncOnCreate", true);
|
||||||
|
Config.ConvertFactionSpecific = sConfigMgr->GetOption<bool>("AccountBound.Mounts.ConvertFactionSpecific", true);
|
||||||
|
Config.RespectFactionRestrictions = sConfigMgr->GetOption<bool>("AccountBound.Mounts.RespectFactionRestrictions", true);
|
||||||
|
Config.RequireRiding = sConfigMgr->GetOption<bool>("AccountBound.Mounts.RequireRiding", true);
|
||||||
|
Config.SyncClassMounts = sConfigMgr->GetOption<bool>("AccountBound.Mounts.SyncClassMounts", true);
|
||||||
|
Config.RequireClass = sConfigMgr->GetOption<bool>("AccountBound.Mounts.RequireClass", true);
|
||||||
|
Config.ClassMountsSameFactionOnly = sConfigMgr->GetOption<bool>("AccountBound.Mounts.ClassMountsSameFactionOnly", true);
|
||||||
|
Config.CleanupInvalid = sConfigMgr->GetOption<bool>("AccountBound.Mounts.CleanupInvalid", true);
|
||||||
|
Config.BackfillOnRidingSkillChange = sConfigMgr->GetOption<bool>("AccountBound.Mounts.BackfillOnRidingSkillChange", true);
|
||||||
|
Config.Filter = AccountBound::LoadIdFilter("Mounts");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountBoundMountsWorldScript : public WorldScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundMountsWorldScript() : WorldScript("AccountBoundMountsWorldScript", {
|
||||||
|
WORLDHOOK_ON_AFTER_CONFIG_LOAD,
|
||||||
|
WORLDHOOK_ON_STARTUP
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnAfterConfigLoad(bool reload) override
|
||||||
|
{
|
||||||
|
LoadModuleConfig();
|
||||||
|
LOG_INFO("module.accountboundmounts",
|
||||||
|
"AccountBoundMounts: {}. CreateSync={}, StartupBackfill={}, RequireRiding={}, RequireClass={}.",
|
||||||
|
Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled",
|
||||||
|
Config.Enabled && Config.SyncOnCreate ? "on" : "off",
|
||||||
|
Config.Enabled && Config.StartupBackfill ? "on" : "off",
|
||||||
|
Config.Enabled && Config.RequireRiding ? "on" : "off",
|
||||||
|
Config.Enabled && Config.RequireClass ? "on" : "off");
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnStartup() override
|
||||||
|
{
|
||||||
|
if (!Config.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BuildMountCache();
|
||||||
|
CleanupInvalidMounts();
|
||||||
|
BackfillAllMounts();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccountBoundMountsPlayerScript : public PlayerScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundMountsPlayerScript() : PlayerScript("AccountBoundMountsPlayerScript", {
|
||||||
|
PLAYERHOOK_ON_CREATE,
|
||||||
|
PLAYERHOOK_ON_LEARN_SPELL,
|
||||||
|
PLAYERHOOK_ON_SET_SKILL
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnPlayerCreate(Player* player) override
|
||||||
|
{
|
||||||
|
if (Config.SyncOnCreate)
|
||||||
|
BackfillMountsForCharacter(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerLearnSpell(Player* player, uint32 spellId) override
|
||||||
|
{
|
||||||
|
SyncMountToAccount(player, spellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerSetSkill(Player* player, uint32 skillId, uint32 value, uint32 /*max*/, uint32 /*step*/, uint32 newValue) override
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !Config.BackfillOnRidingSkillChange || skillId != SKILL_RIDING || newValue <= value)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BackfillMountsForCharacter(player);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void AddAccountBoundMountsScripts()
|
||||||
|
{
|
||||||
|
new AccountBoundMountsWorldScript();
|
||||||
|
new AccountBoundMountsPlayerScript();
|
||||||
|
}
|
||||||
@@ -0,0 +1,348 @@
|
|||||||
|
/*
|
||||||
|
* Account-bound companion pets for AzerothCore.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "AccountBound.h"
|
||||||
|
#include "Config.h"
|
||||||
|
#include "DatabaseEnv.h"
|
||||||
|
#include "DBCStores.h"
|
||||||
|
#include "Log.h"
|
||||||
|
#include "Player.h"
|
||||||
|
#include "PlayerScript.h"
|
||||||
|
#include "SpellInfo.h"
|
||||||
|
#include "SpellMgr.h"
|
||||||
|
#include "StringFormat.h"
|
||||||
|
#include "WorldScript.h"
|
||||||
|
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct ModuleConfig
|
||||||
|
{
|
||||||
|
bool Enabled = true;
|
||||||
|
bool StartupBackfill = true;
|
||||||
|
bool SyncOnCreate = true;
|
||||||
|
bool SameFactionOnly = false;
|
||||||
|
bool IncludeCompanionSkillLine = true;
|
||||||
|
bool IncludeMinipetSummons = true;
|
||||||
|
AccountBound::IdFilter Filter;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CharacterInfo
|
||||||
|
{
|
||||||
|
uint32 Guid = 0;
|
||||||
|
uint8 Race = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
ModuleConfig Config;
|
||||||
|
std::unordered_set<uint32> CompanionSpells;
|
||||||
|
|
||||||
|
bool HasCompanionSkillLine(uint32 spellId)
|
||||||
|
{
|
||||||
|
SkillLineAbilityMapBounds bounds = sSpellMgr->GetSkillLineAbilityMapBounds(spellId);
|
||||||
|
for (SkillLineAbilityMap::const_iterator itr = bounds.first; itr != bounds.second; ++itr)
|
||||||
|
if (itr->second && itr->second->SkillLine == SKILL_COMPANIONS)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsMinipetSummon(SpellInfo const* spellInfo)
|
||||||
|
{
|
||||||
|
if (!spellInfo)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
for (uint8 effectIndex = 0; effectIndex < MAX_SPELL_EFFECTS; ++effectIndex)
|
||||||
|
{
|
||||||
|
SpellEffectInfo const& effect = spellInfo->Effects[effectIndex];
|
||||||
|
if (effect.Effect != SPELL_EFFECT_SUMMON || effect.MiscValue <= 0)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
SummonPropertiesEntry const* properties = sSummonPropertiesStore.LookupEntry(effect.MiscValueB);
|
||||||
|
if (properties && properties->Type == SUMMON_TYPE_MINIPET)
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsCompanionSpell(uint32 spellId)
|
||||||
|
{
|
||||||
|
return Config.Filter.Allows(spellId) && CompanionSpells.find(spellId) != CompanionSpells.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildCompanionCache()
|
||||||
|
{
|
||||||
|
CompanionSpells.clear();
|
||||||
|
|
||||||
|
for (uint32 spellId = 1; spellId < sSpellMgr->GetSpellInfoStoreSize(); ++spellId)
|
||||||
|
{
|
||||||
|
SpellInfo const* spellInfo = sSpellMgr->GetSpellInfo(spellId);
|
||||||
|
if (!spellInfo)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
bool const companionSkill = Config.IncludeCompanionSkillLine && HasCompanionSkillLine(spellId);
|
||||||
|
bool const minipetSummon = Config.IncludeMinipetSummons && IsMinipetSummon(spellInfo);
|
||||||
|
if (companionSkill || minipetSummon)
|
||||||
|
CompanionSpells.insert(spellId);
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("module.accountboundpets",
|
||||||
|
"AccountBoundPets: cached {} companion pet spell(s).", CompanionSpells.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CanShareBetweenRaces(uint8 sourceRace, uint8 targetRace)
|
||||||
|
{
|
||||||
|
return !Config.SameFactionOnly || Player::TeamIdForRace(sourceRace) == Player::TeamIdForRace(targetRace);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql)
|
||||||
|
{
|
||||||
|
if (!trans)
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
|
||||||
|
trans->Append(sql);
|
||||||
|
|
||||||
|
if (trans->GetSize() >= 1000)
|
||||||
|
{
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommitIfNeeded(CharacterDatabaseTransaction& trans)
|
||||||
|
{
|
||||||
|
if (trans && trans->GetSize())
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
void InsertCompanionForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, uint32 spellId)
|
||||||
|
{
|
||||||
|
AppendOrCommit(trans, Acore::StringFormat(
|
||||||
|
"INSERT IGNORE INTO character_spell (guid, spell, specMask) VALUES ({}, {}, {})",
|
||||||
|
targetGuid, spellId, uint32(SPEC_MASK_ALL)));
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<CharacterInfo> LoadAccountCharacters(uint32 accountId)
|
||||||
|
{
|
||||||
|
std::vector<CharacterInfo> characters;
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT guid, race FROM characters WHERE account = {}", accountId);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return characters;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
characters.push_back({
|
||||||
|
fields[0].Get<uint32>(),
|
||||||
|
fields[1].Get<uint8>()
|
||||||
|
});
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
return characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncCompanionToAccount(Player* player, uint32 spellId)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !player || !IsCompanionSpell(spellId))
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 const sourceGuid = player->GetGUID().GetCounter();
|
||||||
|
uint8 const sourceRace = player->getRace(true);
|
||||||
|
std::vector<CharacterInfo> characters = LoadAccountCharacters(player->GetSession()->GetAccountId());
|
||||||
|
CharacterDatabaseTransaction trans;
|
||||||
|
uint32 synced = 0;
|
||||||
|
|
||||||
|
for (CharacterInfo const& target : characters)
|
||||||
|
{
|
||||||
|
if (target.Guid == sourceGuid || !CanShareBetweenRaces(sourceRace, target.Race))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertCompanionForCharacter(trans, target.Guid, spellId);
|
||||||
|
++synced;
|
||||||
|
}
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
if (synced)
|
||||||
|
LOG_DEBUG("module.accountboundpets",
|
||||||
|
"AccountBoundPets: synced companion spell {} from player {} to {} account character(s).",
|
||||||
|
spellId, sourceGuid, synced);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SeedCompanionsForCharacter(Player* player)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 const accountId = player->GetSession()->GetAccountId();
|
||||||
|
uint32 const targetGuid = player->GetGUID().GetCounter();
|
||||||
|
uint8 const targetRace = player->getRace(true);
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT DISTINCT cs.spell, c.race "
|
||||||
|
"FROM character_spell cs "
|
||||||
|
"INNER JOIN characters c ON c.guid = cs.guid "
|
||||||
|
"WHERE c.account = {} AND c.guid <> {}",
|
||||||
|
accountId, targetGuid);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans;
|
||||||
|
uint32 inserted = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint32 const spellId = fields[0].Get<uint32>();
|
||||||
|
uint8 const sourceRace = fields[1].Get<uint8>();
|
||||||
|
|
||||||
|
if (!IsCompanionSpell(spellId) || !CanShareBetweenRaces(sourceRace, targetRace))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertCompanionForCharacter(trans, targetGuid, spellId);
|
||||||
|
++inserted;
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
if (inserted)
|
||||||
|
LOG_INFO("module.accountboundpets",
|
||||||
|
"AccountBoundPets: seeded {} companion spell row(s) for character {}.",
|
||||||
|
inserted, targetGuid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillAllCompanions()
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !Config.StartupBackfill)
|
||||||
|
return;
|
||||||
|
|
||||||
|
QueryResult charactersResult = CharacterDatabase.Query(
|
||||||
|
"SELECT guid, account, race FROM characters WHERE account <> 0");
|
||||||
|
|
||||||
|
if (!charactersResult)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::unordered_map<uint32, std::vector<CharacterInfo>> charactersByAccount;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = charactersResult->Fetch();
|
||||||
|
charactersByAccount[fields[1].Get<uint32>()].push_back({
|
||||||
|
fields[0].Get<uint32>(),
|
||||||
|
fields[2].Get<uint8>()
|
||||||
|
});
|
||||||
|
} while (charactersResult->NextRow());
|
||||||
|
|
||||||
|
QueryResult spellsResult = CharacterDatabase.Query(
|
||||||
|
"SELECT DISTINCT c.account, c.race, cs.spell "
|
||||||
|
"FROM character_spell cs "
|
||||||
|
"INNER JOIN characters c ON c.guid = cs.guid "
|
||||||
|
"WHERE c.account <> 0");
|
||||||
|
|
||||||
|
if (!spellsResult)
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans;
|
||||||
|
uint64 queued = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = spellsResult->Fetch();
|
||||||
|
uint32 const accountId = fields[0].Get<uint32>();
|
||||||
|
uint8 const sourceRace = fields[1].Get<uint8>();
|
||||||
|
uint32 const spellId = fields[2].Get<uint32>();
|
||||||
|
|
||||||
|
if (!IsCompanionSpell(spellId))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
auto accountItr = charactersByAccount.find(accountId);
|
||||||
|
if (accountItr == charactersByAccount.end())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
for (CharacterInfo const& target : accountItr->second)
|
||||||
|
{
|
||||||
|
if (!CanShareBetweenRaces(sourceRace, target.Race))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
InsertCompanionForCharacter(trans, target.Guid, spellId);
|
||||||
|
++queued;
|
||||||
|
}
|
||||||
|
} while (spellsResult->NextRow());
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
LOG_INFO("module.accountboundpets",
|
||||||
|
"AccountBoundPets: startup backfill queued {} companion spell row(s).", queued);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadModuleConfig()
|
||||||
|
{
|
||||||
|
Config.Enabled = AccountBound::IsCategoryEnabled("Pets");
|
||||||
|
Config.StartupBackfill = sConfigMgr->GetOption<bool>("AccountBound.Pets.StartupBackfill", true);
|
||||||
|
Config.SyncOnCreate = sConfigMgr->GetOption<bool>("AccountBound.Pets.SyncOnCreate", true);
|
||||||
|
Config.SameFactionOnly = sConfigMgr->GetOption<bool>("AccountBound.Pets.SameFactionOnly", false);
|
||||||
|
Config.IncludeCompanionSkillLine = sConfigMgr->GetOption<bool>("AccountBound.Pets.IncludeCompanionSkillLine", true);
|
||||||
|
Config.IncludeMinipetSummons = sConfigMgr->GetOption<bool>("AccountBound.Pets.IncludeMinipetSummons", true);
|
||||||
|
Config.Filter = AccountBound::LoadIdFilter("Pets");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountBoundPetsWorldScript : public WorldScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundPetsWorldScript() : WorldScript("AccountBoundPetsWorldScript", {
|
||||||
|
WORLDHOOK_ON_AFTER_CONFIG_LOAD,
|
||||||
|
WORLDHOOK_ON_STARTUP
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnAfterConfigLoad(bool reload) override
|
||||||
|
{
|
||||||
|
LoadModuleConfig();
|
||||||
|
LOG_INFO("module.accountboundpets",
|
||||||
|
"AccountBoundPets: {}. StartupBackfill={}, CreateSync={}, SameFactionOnly={}.",
|
||||||
|
Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled",
|
||||||
|
Config.Enabled && Config.StartupBackfill ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SyncOnCreate ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SameFactionOnly ? "on" : "off");
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnStartup() override
|
||||||
|
{
|
||||||
|
if (!Config.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BuildCompanionCache();
|
||||||
|
BackfillAllCompanions();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccountBoundPetsPlayerScript : public PlayerScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundPetsPlayerScript() : PlayerScript("AccountBoundPetsPlayerScript", {
|
||||||
|
PLAYERHOOK_ON_CREATE,
|
||||||
|
PLAYERHOOK_ON_LEARN_SPELL
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnPlayerCreate(Player* player) override
|
||||||
|
{
|
||||||
|
if (Config.SyncOnCreate)
|
||||||
|
SeedCompanionsForCharacter(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerLearnSpell(Player* player, uint32 spellId) override
|
||||||
|
{
|
||||||
|
SyncCompanionToAccount(player, spellId);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void AddAccountBoundPetsScripts()
|
||||||
|
{
|
||||||
|
new AccountBoundPetsWorldScript();
|
||||||
|
new AccountBoundPetsPlayerScript();
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,419 @@
|
|||||||
|
/*
|
||||||
|
* Account-bound reputations for AzerothCore.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "AccountBound.h"
|
||||||
|
#include "Config.h"
|
||||||
|
#include "DatabaseEnv.h"
|
||||||
|
#include "DBCStores.h"
|
||||||
|
#include "Log.h"
|
||||||
|
#include "ObjectMgr.h"
|
||||||
|
#include "Player.h"
|
||||||
|
#include "PlayerScript.h"
|
||||||
|
#include "ReputationMgr.h"
|
||||||
|
#include "StringFormat.h"
|
||||||
|
#include "WorldScript.h"
|
||||||
|
|
||||||
|
#include <algorithm>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
struct ModuleConfig
|
||||||
|
{
|
||||||
|
bool Enabled = true;
|
||||||
|
bool StartupBackfill = true;
|
||||||
|
bool SyncOnCreate = true;
|
||||||
|
bool SyncOnChange = true;
|
||||||
|
bool SameFactionOnly = true;
|
||||||
|
bool ConvertFactionSpecific = true;
|
||||||
|
bool SyncUnpairedCrossFaction = false;
|
||||||
|
AccountBound::IdFilter Filter;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct ReputationInfo
|
||||||
|
{
|
||||||
|
int32 Standing = 0;
|
||||||
|
uint16 Flags = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
struct CharacterInfo
|
||||||
|
{
|
||||||
|
uint32 Guid = 0;
|
||||||
|
uint32 AccountId = 0;
|
||||||
|
uint8 Race = 0;
|
||||||
|
uint8 Class = 0;
|
||||||
|
std::unordered_map<uint32, ReputationInfo> Reputations;
|
||||||
|
};
|
||||||
|
|
||||||
|
ModuleConfig Config;
|
||||||
|
|
||||||
|
void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql)
|
||||||
|
{
|
||||||
|
if (!trans)
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
|
||||||
|
trans->Append(sql);
|
||||||
|
|
||||||
|
if (trans->GetSize() >= 1000)
|
||||||
|
{
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommitIfNeeded(CharacterDatabaseTransaction& trans)
|
||||||
|
{
|
||||||
|
if (trans && trans->GetSize())
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool CanShareBetweenRaces(uint8 sourceRace, uint8 targetRace)
|
||||||
|
{
|
||||||
|
return !Config.SameFactionOnly || Player::TeamIdForRace(sourceRace) == Player::TeamIdForRace(targetRace);
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 GetFactionForTarget(uint32 factionId, uint8 sourceRace, uint8 targetRace)
|
||||||
|
{
|
||||||
|
if (!Config.Filter.Allows(factionId))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
TeamId const sourceTeam = Player::TeamIdForRace(sourceRace);
|
||||||
|
TeamId const targetTeam = Player::TeamIdForRace(targetRace);
|
||||||
|
|
||||||
|
if (sourceTeam == targetTeam)
|
||||||
|
return factionId;
|
||||||
|
|
||||||
|
if (Config.SameFactionOnly)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
if (Config.ConvertFactionSpecific)
|
||||||
|
{
|
||||||
|
for (auto const& [allianceFaction, hordeFaction] : sObjectMgr->FactionChangeReputation)
|
||||||
|
{
|
||||||
|
if (sourceTeam == TEAM_ALLIANCE && factionId == allianceFaction)
|
||||||
|
return Config.Filter.Allows(targetTeam == TEAM_HORDE ? hordeFaction : allianceFaction) ?
|
||||||
|
(targetTeam == TEAM_HORDE ? hordeFaction : allianceFaction) : 0;
|
||||||
|
|
||||||
|
if (sourceTeam == TEAM_HORDE && factionId == hordeFaction)
|
||||||
|
return Config.Filter.Allows(targetTeam == TEAM_ALLIANCE ? allianceFaction : hordeFaction) ?
|
||||||
|
(targetTeam == TEAM_ALLIANCE ? allianceFaction : hordeFaction) : 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Config.SyncUnpairedCrossFaction ? factionId : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 GetAbsoluteStanding(CharacterInfo const& character, uint32 factionId, ReputationInfo const& reputation)
|
||||||
|
{
|
||||||
|
FactionEntry const* faction = sFactionStore.LookupEntry(factionId);
|
||||||
|
if (!faction || !faction->CanHaveReputation())
|
||||||
|
return ReputationMgr::Reputation_Bottom;
|
||||||
|
|
||||||
|
int32 const base = sObjectMgr->GetBaseReputationOf(faction, character.Race, character.Class);
|
||||||
|
return std::clamp(base + reputation.Standing, ReputationMgr::Reputation_Bottom, ReputationMgr::Reputation_Cap);
|
||||||
|
}
|
||||||
|
|
||||||
|
int32 GetStoredStanding(CharacterInfo const& character, uint32 factionId, int32 absoluteStanding)
|
||||||
|
{
|
||||||
|
FactionEntry const* faction = sFactionStore.LookupEntry(factionId);
|
||||||
|
if (!faction)
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
int32 const base = sObjectMgr->GetBaseReputationOf(faction, character.Race, character.Class);
|
||||||
|
return std::clamp(absoluteStanding, ReputationMgr::Reputation_Bottom, ReputationMgr::Reputation_Cap) - base;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::vector<CharacterInfo> LoadCharacters(uint32 accountId = 0)
|
||||||
|
{
|
||||||
|
std::vector<CharacterInfo> characters;
|
||||||
|
std::unordered_map<uint32, std::size_t> characterIndexes;
|
||||||
|
QueryResult result;
|
||||||
|
|
||||||
|
if (accountId)
|
||||||
|
{
|
||||||
|
result = CharacterDatabase.Query(
|
||||||
|
"SELECT c.guid, c.account, c.race, c.class, cr.faction, cr.standing, cr.flags "
|
||||||
|
"FROM characters c "
|
||||||
|
"LEFT JOIN character_reputation cr ON cr.guid = c.guid "
|
||||||
|
"WHERE c.account = {} "
|
||||||
|
"ORDER BY c.guid",
|
||||||
|
accountId);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
result = CharacterDatabase.Query(
|
||||||
|
"SELECT c.guid, c.account, c.race, c.class, cr.faction, cr.standing, cr.flags "
|
||||||
|
"FROM characters c "
|
||||||
|
"LEFT JOIN character_reputation cr ON cr.guid = c.guid "
|
||||||
|
"WHERE c.account <> 0 "
|
||||||
|
"ORDER BY c.account, c.guid");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return characters;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint32 const guid = fields[0].Get<uint32>();
|
||||||
|
auto [itr, inserted] = characterIndexes.try_emplace(guid, characters.size());
|
||||||
|
|
||||||
|
if (inserted)
|
||||||
|
{
|
||||||
|
characters.push_back({
|
||||||
|
guid,
|
||||||
|
fields[1].Get<uint32>(),
|
||||||
|
fields[2].Get<uint8>(),
|
||||||
|
fields[3].Get<uint8>(),
|
||||||
|
{}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fields[4].IsNull())
|
||||||
|
{
|
||||||
|
characters[itr->second].Reputations[fields[4].Get<uint32>()] = {
|
||||||
|
fields[5].Get<int32>(),
|
||||||
|
fields[6].Get<uint16>()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
return characters;
|
||||||
|
}
|
||||||
|
|
||||||
|
void PersistReputation(
|
||||||
|
CharacterDatabaseTransaction& trans,
|
||||||
|
CharacterInfo const& target,
|
||||||
|
uint32 factionId,
|
||||||
|
int32 absoluteStanding,
|
||||||
|
uint16 flags)
|
||||||
|
{
|
||||||
|
int32 const storedStanding = GetStoredStanding(target, factionId, absoluteStanding);
|
||||||
|
AppendOrCommit(trans, Acore::StringFormat(
|
||||||
|
"INSERT INTO character_reputation (guid, faction, standing, flags) "
|
||||||
|
"VALUES ({}, {}, {}, {}) "
|
||||||
|
"ON DUPLICATE KEY UPDATE standing = VALUES(standing), flags = flags | VALUES(flags)",
|
||||||
|
target.Guid, factionId, storedStanding, flags));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool ApplyReputation(
|
||||||
|
CharacterDatabaseTransaction& trans,
|
||||||
|
CharacterInfo& target,
|
||||||
|
uint32 factionId,
|
||||||
|
int32 absoluteStanding,
|
||||||
|
uint16 flags)
|
||||||
|
{
|
||||||
|
FactionEntry const* faction = sFactionStore.LookupEntry(factionId);
|
||||||
|
if (!faction || !faction->CanHaveReputation())
|
||||||
|
return false;
|
||||||
|
|
||||||
|
int32 currentStanding = sObjectMgr->GetBaseReputationOf(faction, target.Race, target.Class);
|
||||||
|
auto currentItr = target.Reputations.find(factionId);
|
||||||
|
if (currentItr != target.Reputations.end())
|
||||||
|
currentStanding = GetAbsoluteStanding(target, factionId, currentItr->second);
|
||||||
|
|
||||||
|
absoluteStanding = std::clamp(absoluteStanding, ReputationMgr::Reputation_Bottom, ReputationMgr::Reputation_Cap);
|
||||||
|
if (absoluteStanding <= currentStanding)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
uint16 const mergedFlags = currentItr == target.Reputations.end() ? flags : uint16(currentItr->second.Flags | flags);
|
||||||
|
PersistReputation(trans, target, factionId, absoluteStanding, mergedFlags);
|
||||||
|
target.Reputations[factionId] = { GetStoredStanding(target, factionId, absoluteStanding), mergedFlags };
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 MergeReputations(CharacterInfo const& source, CharacterInfo& target, CharacterDatabaseTransaction& trans)
|
||||||
|
{
|
||||||
|
if (source.Guid == target.Guid || !CanShareBetweenRaces(source.Race, target.Race))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
uint32 updated = 0;
|
||||||
|
for (auto const& [sourceFactionId, sourceReputation] : source.Reputations)
|
||||||
|
{
|
||||||
|
uint32 const targetFactionId = GetFactionForTarget(sourceFactionId, source.Race, target.Race);
|
||||||
|
if (!targetFactionId)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
int32 const absoluteStanding = GetAbsoluteStanding(source, sourceFactionId, sourceReputation);
|
||||||
|
if (ApplyReputation(trans, target, targetFactionId, absoluteStanding, sourceReputation.Flags))
|
||||||
|
++updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillReputationsForCharacter(Player* player)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::vector<CharacterInfo> characters = LoadCharacters(player->GetSession()->GetAccountId());
|
||||||
|
auto targetItr = std::find_if(characters.begin(), characters.end(), [player](CharacterInfo const& character)
|
||||||
|
{
|
||||||
|
return character.Guid == player->GetGUID().GetCounter();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetItr == characters.end())
|
||||||
|
return;
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint32 updated = 0;
|
||||||
|
for (CharacterInfo const& source : characters)
|
||||||
|
updated += MergeReputations(source, *targetItr, trans);
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
if (updated)
|
||||||
|
LOG_INFO("module.accountboundreputations",
|
||||||
|
"AccountBoundReputations: seeded {} improved reputation(s) for character {}.",
|
||||||
|
updated, targetItr->Guid);
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncReputationToAccount(Player* player, uint32 factionId, int32 absoluteStanding)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !Config.SyncOnChange || !player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
FactionEntry const* sourceFaction = sFactionStore.LookupEntry(factionId);
|
||||||
|
if (!sourceFaction || !sourceFaction->CanHaveReputation())
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::vector<CharacterInfo> characters = LoadCharacters(player->GetSession()->GetAccountId());
|
||||||
|
auto sourceItr = std::find_if(characters.begin(), characters.end(), [player](CharacterInfo const& character)
|
||||||
|
{
|
||||||
|
return character.Guid == player->GetGUID().GetCounter();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (sourceItr == characters.end())
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint16 flags = 0;
|
||||||
|
if (FactionState const* state = player->GetReputationMgr().GetState(sourceFaction))
|
||||||
|
flags = state->Flags;
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint32 updated = 0;
|
||||||
|
|
||||||
|
for (CharacterInfo& target : characters)
|
||||||
|
{
|
||||||
|
if (target.Guid == sourceItr->Guid || !CanShareBetweenRaces(sourceItr->Race, target.Race))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint32 const targetFactionId = GetFactionForTarget(factionId, sourceItr->Race, target.Race);
|
||||||
|
if (targetFactionId && ApplyReputation(trans, target, targetFactionId, absoluteStanding, flags))
|
||||||
|
++updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
if (updated)
|
||||||
|
LOG_DEBUG("module.accountboundreputations",
|
||||||
|
"AccountBoundReputations: propagated faction {} from character {} to {} account character(s).",
|
||||||
|
factionId, sourceItr->Guid, updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillAllReputations()
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !Config.StartupBackfill)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::vector<CharacterInfo> characters = LoadCharacters();
|
||||||
|
std::unordered_map<uint32, std::vector<std::size_t>> charactersByAccount;
|
||||||
|
|
||||||
|
for (std::size_t index = 0; index < characters.size(); ++index)
|
||||||
|
charactersByAccount[characters[index].AccountId].push_back(index);
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint64 updated = 0;
|
||||||
|
|
||||||
|
for (auto const& [accountId, indexes] : charactersByAccount)
|
||||||
|
{
|
||||||
|
(void)accountId;
|
||||||
|
std::vector<CharacterInfo> const snapshot = [&]()
|
||||||
|
{
|
||||||
|
std::vector<CharacterInfo> result;
|
||||||
|
result.reserve(indexes.size());
|
||||||
|
for (std::size_t index : indexes)
|
||||||
|
result.push_back(characters[index]);
|
||||||
|
return result;
|
||||||
|
}();
|
||||||
|
|
||||||
|
for (std::size_t targetIndex : indexes)
|
||||||
|
for (CharacterInfo const& source : snapshot)
|
||||||
|
updated += MergeReputations(source, characters[targetIndex], trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
LOG_INFO("module.accountboundreputations",
|
||||||
|
"AccountBoundReputations: startup backfill queued {} improved reputation row(s).", updated);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadModuleConfig()
|
||||||
|
{
|
||||||
|
Config.Enabled = AccountBound::IsCategoryEnabled("Reputations");
|
||||||
|
Config.StartupBackfill = sConfigMgr->GetOption<bool>("AccountBound.Reputations.StartupBackfill", true);
|
||||||
|
Config.SyncOnCreate = sConfigMgr->GetOption<bool>("AccountBound.Reputations.SyncOnCreate", true);
|
||||||
|
Config.SyncOnChange = sConfigMgr->GetOption<bool>("AccountBound.Reputations.SyncOnChange", true);
|
||||||
|
Config.SameFactionOnly = sConfigMgr->GetOption<bool>("AccountBound.Reputations.SameFactionOnly", true);
|
||||||
|
Config.ConvertFactionSpecific = sConfigMgr->GetOption<bool>("AccountBound.Reputations.ConvertFactionSpecific", true);
|
||||||
|
Config.SyncUnpairedCrossFaction = sConfigMgr->GetOption<bool>("AccountBound.Reputations.SyncUnpairedCrossFaction", false);
|
||||||
|
Config.Filter = AccountBound::LoadIdFilter("Reputations");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountBoundReputationsWorldScript : public WorldScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundReputationsWorldScript() : WorldScript("AccountBoundReputationsWorldScript", {
|
||||||
|
WORLDHOOK_ON_AFTER_CONFIG_LOAD,
|
||||||
|
WORLDHOOK_ON_STARTUP
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnAfterConfigLoad(bool reload) override
|
||||||
|
{
|
||||||
|
LoadModuleConfig();
|
||||||
|
LOG_INFO("module.accountboundreputations",
|
||||||
|
"AccountBoundReputations: {}. CreateSync={}, ChangeSync={}, StartupBackfill={}, SameFactionOnly={}.",
|
||||||
|
Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled",
|
||||||
|
Config.Enabled && Config.SyncOnCreate ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SyncOnChange ? "on" : "off",
|
||||||
|
Config.Enabled && Config.StartupBackfill ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SameFactionOnly ? "on" : "off");
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnStartup() override
|
||||||
|
{
|
||||||
|
BackfillAllReputations();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccountBoundReputationsPlayerScript : public PlayerScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundReputationsPlayerScript() : PlayerScript("AccountBoundReputationsPlayerScript", {
|
||||||
|
PLAYERHOOK_ON_CREATE,
|
||||||
|
PLAYERHOOK_ON_REPUTATION_CHANGE
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnPlayerCreate(Player* player) override
|
||||||
|
{
|
||||||
|
if (Config.SyncOnCreate)
|
||||||
|
BackfillReputationsForCharacter(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool OnPlayerReputationChange(Player* player, uint32 factionId, int32& standing, bool /*incremental*/) override
|
||||||
|
{
|
||||||
|
SyncReputationToAccount(player, factionId, standing);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void AddAccountBoundReputationsScripts()
|
||||||
|
{
|
||||||
|
new AccountBoundReputationsWorldScript();
|
||||||
|
new AccountBoundReputationsPlayerScript();
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
/*
|
||||||
|
* Account-bound character titles for AzerothCore.
|
||||||
|
*/
|
||||||
|
|
||||||
|
#include "AchievementMgr.h"
|
||||||
|
#include "AccountBound.h"
|
||||||
|
#include "Config.h"
|
||||||
|
#include "DatabaseEnv.h"
|
||||||
|
#include "DBCStores.h"
|
||||||
|
#include "Log.h"
|
||||||
|
#include "ObjectMgr.h"
|
||||||
|
#include "Player.h"
|
||||||
|
#include "PlayerScript.h"
|
||||||
|
#include "StringFormat.h"
|
||||||
|
#include "WorldScript.h"
|
||||||
|
|
||||||
|
#include <array>
|
||||||
|
#include <sstream>
|
||||||
|
#include <string_view>
|
||||||
|
#include <unordered_map>
|
||||||
|
#include <unordered_set>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
namespace
|
||||||
|
{
|
||||||
|
using KnownTitlesMask = std::array<uint32, KNOWN_TITLES_SIZE * 2>;
|
||||||
|
|
||||||
|
struct ModuleConfig
|
||||||
|
{
|
||||||
|
bool Enabled = true;
|
||||||
|
bool StartupBackfill = true;
|
||||||
|
bool SyncOnCreate = true;
|
||||||
|
bool SyncOnSave = true;
|
||||||
|
bool SyncRealmFirst = false;
|
||||||
|
bool ConvertFactionSpecific = true;
|
||||||
|
AccountBound::IdFilter Filter;
|
||||||
|
};
|
||||||
|
|
||||||
|
ModuleConfig Config;
|
||||||
|
std::unordered_map<uint32, uint32> TitleIdByBitIndex;
|
||||||
|
std::unordered_set<uint32> RealmFirstTitleIds;
|
||||||
|
std::unordered_map<uint32, KnownTitlesMask> LastKnownTitlesByCharacter;
|
||||||
|
|
||||||
|
bool IsRealmFirstAchievement(AchievementEntry const* achievement)
|
||||||
|
{
|
||||||
|
return achievement && (achievement->flags & (ACHIEVEMENT_FLAG_REALM_FIRST_REACH | ACHIEVEMENT_FLAG_REALM_FIRST_KILL));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool IsTitleAllowed(uint32 titleId)
|
||||||
|
{
|
||||||
|
return Config.Filter.Allows(titleId) &&
|
||||||
|
(Config.SyncRealmFirst || !RealmFirstTitleIds.contains(titleId));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BuildTitleCache()
|
||||||
|
{
|
||||||
|
TitleIdByBitIndex.clear();
|
||||||
|
RealmFirstTitleIds =
|
||||||
|
{
|
||||||
|
// Restored beta/datamined Realm First titles used by RealmFirstTitles.
|
||||||
|
85, 86, 87, 89, 90, 91, 92, 93, 94, 95,
|
||||||
|
96, 97, 98, 99, 100, 101, 102, 103, 104, 105,
|
||||||
|
106, 107, 108, 109, 110, 111, 112, 113, 114, 115,
|
||||||
|
116, 117, 118, 119, 123, 156
|
||||||
|
};
|
||||||
|
|
||||||
|
for (uint32 titleId = 0; titleId < sCharTitlesStore.GetNumRows(); ++titleId)
|
||||||
|
if (CharTitlesEntry const* title = sCharTitlesStore.LookupEntry(titleId))
|
||||||
|
TitleIdByBitIndex.try_emplace(title->bit_index, titleId);
|
||||||
|
|
||||||
|
for (uint32 achievementId = 0; achievementId < sAchievementStore.GetNumRows(); ++achievementId)
|
||||||
|
{
|
||||||
|
AchievementEntry const* achievement = sAchievementStore.LookupEntry(achievementId);
|
||||||
|
if (!IsRealmFirstAchievement(achievement))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
if (AchievementReward const* reward = sAchievementMgr->GetAchievementReward(achievement))
|
||||||
|
for (uint32 titleId : reward->titleId)
|
||||||
|
if (titleId)
|
||||||
|
RealmFirstTitleIds.insert(titleId);
|
||||||
|
}
|
||||||
|
|
||||||
|
LOG_INFO("module.accountboundtitles",
|
||||||
|
"AccountBoundTitles: cached {} title bit mapping(s) and {} Realm First exclusion(s).",
|
||||||
|
TitleIdByBitIndex.size(), RealmFirstTitleIds.size());
|
||||||
|
}
|
||||||
|
|
||||||
|
KnownTitlesMask ParseKnownTitles(std::string const& text)
|
||||||
|
{
|
||||||
|
KnownTitlesMask titles = {};
|
||||||
|
std::stringstream stream(text);
|
||||||
|
|
||||||
|
for (uint32& value : titles)
|
||||||
|
stream >> value;
|
||||||
|
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
KnownTitlesMask GetPlayerKnownTitles(Player const* player)
|
||||||
|
{
|
||||||
|
KnownTitlesMask titles = {};
|
||||||
|
if (!player)
|
||||||
|
return titles;
|
||||||
|
|
||||||
|
for (uint32 index = 0; index < titles.size(); ++index)
|
||||||
|
titles[index] = player->GetUInt32Value(PLAYER__FIELD_KNOWN_TITLES + index);
|
||||||
|
|
||||||
|
return titles;
|
||||||
|
}
|
||||||
|
|
||||||
|
std::string KnownTitlesToString(KnownTitlesMask const& titles)
|
||||||
|
{
|
||||||
|
std::ostringstream stream;
|
||||||
|
|
||||||
|
for (uint32 index = 0; index < titles.size(); ++index)
|
||||||
|
{
|
||||||
|
if (index)
|
||||||
|
stream << ' ';
|
||||||
|
|
||||||
|
stream << titles[index];
|
||||||
|
}
|
||||||
|
|
||||||
|
return stream.str();
|
||||||
|
}
|
||||||
|
|
||||||
|
bool HasKnownTitle(KnownTitlesMask const& titles, uint32 bitIndex)
|
||||||
|
{
|
||||||
|
if (bitIndex >= titles.size() * 32)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
return (titles[bitIndex / 32] & (1u << (bitIndex % 32))) != 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
bool AddKnownTitle(KnownTitlesMask& titles, uint32 bitIndex)
|
||||||
|
{
|
||||||
|
if (bitIndex >= titles.size() * 32)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
uint32& mask = titles[bitIndex / 32];
|
||||||
|
uint32 const flag = 1u << (bitIndex % 32);
|
||||||
|
|
||||||
|
if (mask & flag)
|
||||||
|
return false;
|
||||||
|
|
||||||
|
mask |= flag;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 GetTitleForRace(uint32 titleId, uint8 sourceRace, uint8 targetRace)
|
||||||
|
{
|
||||||
|
if (!IsTitleAllowed(titleId))
|
||||||
|
return 0;
|
||||||
|
|
||||||
|
TeamId const sourceTeam = Player::TeamIdForRace(sourceRace);
|
||||||
|
TeamId const targetTeam = Player::TeamIdForRace(targetRace);
|
||||||
|
|
||||||
|
if (sourceTeam == targetTeam)
|
||||||
|
return titleId;
|
||||||
|
|
||||||
|
for (auto const& [allianceTitle, hordeTitle] : sObjectMgr->FactionChangeTitles)
|
||||||
|
{
|
||||||
|
if (titleId == allianceTitle)
|
||||||
|
return Config.ConvertFactionSpecific && targetTeam == TEAM_HORDE &&
|
||||||
|
IsTitleAllowed(hordeTitle) ? hordeTitle : 0;
|
||||||
|
|
||||||
|
if (titleId == hordeTitle)
|
||||||
|
return Config.ConvertFactionSpecific && targetTeam == TEAM_ALLIANCE &&
|
||||||
|
IsTitleAllowed(allianceTitle) ? allianceTitle : 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return titleId;
|
||||||
|
}
|
||||||
|
|
||||||
|
uint32 MergeTitlesForRace(KnownTitlesMask const& sourceTitles, uint8 sourceRace, uint8 targetRace, KnownTitlesMask& targetTitles)
|
||||||
|
{
|
||||||
|
uint32 added = 0;
|
||||||
|
|
||||||
|
for (uint32 sourceBitIndex = 0; sourceBitIndex < sourceTitles.size() * 32; ++sourceBitIndex)
|
||||||
|
{
|
||||||
|
if (!HasKnownTitle(sourceTitles, sourceBitIndex))
|
||||||
|
continue;
|
||||||
|
|
||||||
|
auto titleItr = TitleIdByBitIndex.find(sourceBitIndex);
|
||||||
|
if (titleItr == TitleIdByBitIndex.end())
|
||||||
|
continue;
|
||||||
|
|
||||||
|
uint32 const targetTitleId = GetTitleForRace(titleItr->second, sourceRace, targetRace);
|
||||||
|
CharTitlesEntry const* targetTitle = sCharTitlesStore.LookupEntry(targetTitleId);
|
||||||
|
|
||||||
|
if (targetTitle && AddKnownTitle(targetTitles, targetTitle->bit_index))
|
||||||
|
++added;
|
||||||
|
}
|
||||||
|
|
||||||
|
return added;
|
||||||
|
}
|
||||||
|
|
||||||
|
void AppendOrCommit(CharacterDatabaseTransaction& trans, std::string_view sql)
|
||||||
|
{
|
||||||
|
if (!trans)
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
|
||||||
|
trans->Append(sql);
|
||||||
|
|
||||||
|
if (trans->GetSize() >= 1000)
|
||||||
|
{
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
trans = CharacterDatabase.BeginTransaction();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void CommitIfNeeded(CharacterDatabaseTransaction& trans)
|
||||||
|
{
|
||||||
|
if (trans && trans->GetSize())
|
||||||
|
CharacterDatabase.DirectCommitTransaction(trans);
|
||||||
|
}
|
||||||
|
|
||||||
|
void UpdateTitlesForCharacter(CharacterDatabaseTransaction& trans, uint32 targetGuid, KnownTitlesMask const& titles)
|
||||||
|
{
|
||||||
|
AppendOrCommit(trans, Acore::StringFormat(
|
||||||
|
"UPDATE characters SET knownTitles = '{}' WHERE guid = {}",
|
||||||
|
KnownTitlesToString(titles), targetGuid));
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillTitlesForCharacter(Player* player)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 const accountId = player->GetSession()->GetAccountId();
|
||||||
|
uint32 const targetGuid = player->GetGUID().GetCounter();
|
||||||
|
uint8 const targetRace = player->getRace(true);
|
||||||
|
KnownTitlesMask targetTitles = {};
|
||||||
|
uint32 added = 0;
|
||||||
|
QueryResult targetResult = CharacterDatabase.Query(
|
||||||
|
"SELECT COALESCE(knownTitles, '') FROM characters WHERE guid = {}", targetGuid);
|
||||||
|
|
||||||
|
if (targetResult)
|
||||||
|
targetTitles = ParseKnownTitles(targetResult->Fetch()[0].Get<std::string>());
|
||||||
|
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT guid, race, COALESCE(knownTitles, '') "
|
||||||
|
"FROM characters WHERE account = {} AND guid <> {}",
|
||||||
|
accountId, targetGuid);
|
||||||
|
|
||||||
|
if (result)
|
||||||
|
{
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint8 const sourceRace = fields[1].Get<uint8>();
|
||||||
|
KnownTitlesMask const sourceTitles = ParseKnownTitles(fields[2].Get<std::string>());
|
||||||
|
added += MergeTitlesForRace(sourceTitles, sourceRace, targetRace, targetTitles);
|
||||||
|
} while (result->NextRow());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (added)
|
||||||
|
{
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
UpdateTitlesForCharacter(trans, targetGuid, targetTitles);
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
|
||||||
|
LOG_INFO("module.accountboundtitles",
|
||||||
|
"AccountBoundTitles: seeded {} account title(s) for character {}.",
|
||||||
|
added, targetGuid);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void SyncTitlesFromPlayerToAccount(Player* player)
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !Config.SyncOnSave || !player)
|
||||||
|
return;
|
||||||
|
|
||||||
|
uint32 const sourceGuid = player->GetGUID().GetCounter();
|
||||||
|
uint8 const sourceRace = player->getRace(true);
|
||||||
|
KnownTitlesMask const sourceTitles = GetPlayerKnownTitles(player);
|
||||||
|
auto snapshotItr = LastKnownTitlesByCharacter.find(sourceGuid);
|
||||||
|
|
||||||
|
if (snapshotItr != LastKnownTitlesByCharacter.end() && snapshotItr->second == sourceTitles)
|
||||||
|
return;
|
||||||
|
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT guid, race, COALESCE(knownTitles, '') "
|
||||||
|
"FROM characters WHERE account = {} AND guid <> {}",
|
||||||
|
player->GetSession()->GetAccountId(), sourceGuid);
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
{
|
||||||
|
LastKnownTitlesByCharacter[sourceGuid] = sourceTitles;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint32 updatedCharacters = 0;
|
||||||
|
uint32 addedTitles = 0;
|
||||||
|
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
uint32 const targetGuid = fields[0].Get<uint32>();
|
||||||
|
uint8 const targetRace = fields[1].Get<uint8>();
|
||||||
|
KnownTitlesMask targetTitles = ParseKnownTitles(fields[2].Get<std::string>());
|
||||||
|
uint32 const added = MergeTitlesForRace(sourceTitles, sourceRace, targetRace, targetTitles);
|
||||||
|
|
||||||
|
if (!added)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
UpdateTitlesForCharacter(trans, targetGuid, targetTitles);
|
||||||
|
|
||||||
|
++updatedCharacters;
|
||||||
|
addedTitles += added;
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
LastKnownTitlesByCharacter[sourceGuid] = sourceTitles;
|
||||||
|
|
||||||
|
if (updatedCharacters)
|
||||||
|
LOG_INFO("module.accountboundtitles",
|
||||||
|
"AccountBoundTitles: propagated {} title(s) from character {} to {} account character(s).",
|
||||||
|
addedTitles, sourceGuid, updatedCharacters);
|
||||||
|
}
|
||||||
|
|
||||||
|
void BackfillAllTitles()
|
||||||
|
{
|
||||||
|
if (!Config.Enabled || !Config.StartupBackfill)
|
||||||
|
return;
|
||||||
|
|
||||||
|
struct TitleCharacterInfo
|
||||||
|
{
|
||||||
|
uint32 Guid;
|
||||||
|
uint8 Race;
|
||||||
|
KnownTitlesMask Titles;
|
||||||
|
};
|
||||||
|
|
||||||
|
QueryResult result = CharacterDatabase.Query(
|
||||||
|
"SELECT guid, account, race, COALESCE(knownTitles, '') "
|
||||||
|
"FROM characters WHERE account <> 0");
|
||||||
|
|
||||||
|
if (!result)
|
||||||
|
return;
|
||||||
|
|
||||||
|
std::unordered_map<uint32, std::vector<TitleCharacterInfo>> charactersByAccount;
|
||||||
|
do
|
||||||
|
{
|
||||||
|
Field* fields = result->Fetch();
|
||||||
|
charactersByAccount[fields[1].Get<uint32>()].push_back({
|
||||||
|
fields[0].Get<uint32>(),
|
||||||
|
fields[2].Get<uint8>(),
|
||||||
|
ParseKnownTitles(fields[3].Get<std::string>())
|
||||||
|
});
|
||||||
|
} while (result->NextRow());
|
||||||
|
|
||||||
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
|
uint32 updatedCharacters = 0;
|
||||||
|
uint32 addedTitles = 0;
|
||||||
|
|
||||||
|
for (auto const& [accountId, characters] : charactersByAccount)
|
||||||
|
{
|
||||||
|
(void)accountId;
|
||||||
|
|
||||||
|
for (TitleCharacterInfo const& target : characters)
|
||||||
|
{
|
||||||
|
KnownTitlesMask mergedTitles = target.Titles;
|
||||||
|
uint32 added = 0;
|
||||||
|
|
||||||
|
for (TitleCharacterInfo const& source : characters)
|
||||||
|
{
|
||||||
|
if (source.Guid != target.Guid)
|
||||||
|
added += MergeTitlesForRace(source.Titles, source.Race, target.Race, mergedTitles);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!added)
|
||||||
|
continue;
|
||||||
|
|
||||||
|
UpdateTitlesForCharacter(trans, target.Guid, mergedTitles);
|
||||||
|
++updatedCharacters;
|
||||||
|
addedTitles += added;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
CommitIfNeeded(trans);
|
||||||
|
LOG_INFO("module.accountboundtitles",
|
||||||
|
"AccountBoundTitles: startup backfill added {} title(s) to {} character(s).",
|
||||||
|
addedTitles, updatedCharacters);
|
||||||
|
}
|
||||||
|
|
||||||
|
void LoadModuleConfig()
|
||||||
|
{
|
||||||
|
Config.Enabled = AccountBound::IsCategoryEnabled("Titles");
|
||||||
|
Config.StartupBackfill = sConfigMgr->GetOption<bool>("AccountBound.Titles.StartupBackfill", true);
|
||||||
|
Config.SyncOnCreate = sConfigMgr->GetOption<bool>("AccountBound.Titles.SyncOnCreate", true);
|
||||||
|
Config.SyncOnSave = sConfigMgr->GetOption<bool>("AccountBound.Titles.SyncOnSave", true);
|
||||||
|
Config.SyncRealmFirst = sConfigMgr->GetOption<bool>("AccountBound.Titles.SyncRealmFirst", false);
|
||||||
|
Config.ConvertFactionSpecific = sConfigMgr->GetOption<bool>("AccountBound.Titles.ConvertFactionSpecific", true);
|
||||||
|
Config.Filter = AccountBound::LoadIdFilter("Titles");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class AccountBoundTitlesWorldScript : public WorldScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundTitlesWorldScript() : WorldScript("AccountBoundTitlesWorldScript", {
|
||||||
|
WORLDHOOK_ON_AFTER_CONFIG_LOAD,
|
||||||
|
WORLDHOOK_ON_STARTUP
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnAfterConfigLoad(bool reload) override
|
||||||
|
{
|
||||||
|
LoadModuleConfig();
|
||||||
|
LOG_INFO("module.accountboundtitles",
|
||||||
|
"AccountBoundTitles: {}. CreateSync={}, SaveSync={}, StartupBackfill={}, RealmFirst={}.",
|
||||||
|
Config.Enabled ? (reload ? "configuration reloaded" : "configuration loaded") : "disabled",
|
||||||
|
Config.Enabled && Config.SyncOnCreate ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SyncOnSave ? "on" : "off",
|
||||||
|
Config.Enabled && Config.StartupBackfill ? "on" : "off",
|
||||||
|
Config.Enabled && Config.SyncRealmFirst ? "on" : "off");
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnStartup() override
|
||||||
|
{
|
||||||
|
if (!Config.Enabled)
|
||||||
|
return;
|
||||||
|
|
||||||
|
BuildTitleCache();
|
||||||
|
BackfillAllTitles();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
class AccountBoundTitlesPlayerScript : public PlayerScript
|
||||||
|
{
|
||||||
|
public:
|
||||||
|
AccountBoundTitlesPlayerScript() : PlayerScript("AccountBoundTitlesPlayerScript", {
|
||||||
|
PLAYERHOOK_ON_LOAD_FROM_DB,
|
||||||
|
PLAYERHOOK_ON_LOGOUT,
|
||||||
|
PLAYERHOOK_ON_CREATE,
|
||||||
|
PLAYERHOOK_ON_SAVE
|
||||||
|
}) { }
|
||||||
|
|
||||||
|
void OnPlayerLoadFromDB(Player* player) override
|
||||||
|
{
|
||||||
|
if (player)
|
||||||
|
LastKnownTitlesByCharacter[player->GetGUID().GetCounter()] = GetPlayerKnownTitles(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerCreate(Player* player) override
|
||||||
|
{
|
||||||
|
if (Config.SyncOnCreate)
|
||||||
|
BackfillTitlesForCharacter(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerSave(Player* player) override
|
||||||
|
{
|
||||||
|
SyncTitlesFromPlayerToAccount(player);
|
||||||
|
}
|
||||||
|
|
||||||
|
void OnPlayerLogout(Player* player) override
|
||||||
|
{
|
||||||
|
if (player)
|
||||||
|
LastKnownTitlesByCharacter.erase(player->GetGUID().GetCounter());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void AddAccountBoundTitlesScripts()
|
||||||
|
{
|
||||||
|
new AccountBoundTitlesWorldScript();
|
||||||
|
new AccountBoundTitlesPlayerScript();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
void AddAccountBoundAchievementsScripts();
|
||||||
|
void AddAccountBoundMountsScripts();
|
||||||
|
void AddAccountBoundPetsScripts();
|
||||||
|
void AddAccountBoundProfessionsScripts();
|
||||||
|
void AddAccountBoundReputationsScripts();
|
||||||
|
void AddAccountBoundTitlesScripts();
|
||||||
|
void AddAccountWideFriendsScripts();
|
||||||
|
|
||||||
|
void AddAccountBoundScripts()
|
||||||
|
{
|
||||||
|
AddAccountBoundAchievementsScripts();
|
||||||
|
AddAccountBoundMountsScripts();
|
||||||
|
AddAccountBoundPetsScripts();
|
||||||
|
AddAccountBoundProfessionsScripts();
|
||||||
|
AddAccountBoundReputationsScripts();
|
||||||
|
AddAccountBoundTitlesScripts();
|
||||||
|
AddAccountWideFriendsScripts();
|
||||||
|
}
|
||||||
@@ -61,9 +61,9 @@
|
|||||||
# false - (disabled)
|
# false - (disabled)
|
||||||
|
|
||||||
Eluna.Enabled = true
|
Eluna.Enabled = true
|
||||||
Eluna.TraceBack = false
|
Eluna.TraceBack = true
|
||||||
Eluna.ScriptPath = "lua_scripts"
|
Eluna.ScriptPath = "lua_scripts"
|
||||||
Eluna.PlayerAnnounceReload = false
|
Eluna.PlayerAnnounceReload = true
|
||||||
Eluna.RequirePaths = ""
|
Eluna.RequirePaths = ""
|
||||||
Eluna.RequireCPaths = ""
|
Eluna.RequireCPaths = ""
|
||||||
Eluna.AutoReload = false
|
Eluna.AutoReload = false
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -233,6 +233,7 @@ namespace Hooks
|
|||||||
PLAYER_EVENT_ON_AURA_APPLY = 64, // (event, player, aura)
|
PLAYER_EVENT_ON_AURA_APPLY = 64, // (event, player, aura)
|
||||||
PLAYER_EVENT_ON_HEAL = 65, // (event, player, target, gain) - Can return new heal amount
|
PLAYER_EVENT_ON_HEAL = 65, // (event, player, target, gain) - Can return new heal amount
|
||||||
PLAYER_EVENT_ON_DAMAGE = 66, // (event, player, target, damage) - Can return new damage amount
|
PLAYER_EVENT_ON_DAMAGE = 66, // (event, player, target, damage) - Can return new damage amount
|
||||||
|
PLAYER_EVENT_ON_BEFORE_TELEPORT = 67, // (event, player, mapid, x, y, z, orientation, options, target)
|
||||||
|
|
||||||
PLAYER_EVENT_COUNT
|
PLAYER_EVENT_COUNT
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -497,6 +497,7 @@ public:
|
|||||||
void OnPlayerAuraApply(Player* player, Aura* aura);
|
void OnPlayerAuraApply(Player* player, Aura* aura);
|
||||||
void OnPlayerHeal(Player* player, Unit* target, uint32& gain);
|
void OnPlayerHeal(Player* player, Unit* target, uint32& gain);
|
||||||
void OnPlayerDamage(Player* player, Unit* target, uint32& gain);
|
void OnPlayerDamage(Player* player, Unit* target, uint32& gain);
|
||||||
|
bool OnPlayerBeforeTeleport(Player* player, uint32 mapid, float x, float y, float z, float orientation, uint32 options, Unit* target);
|
||||||
|
|
||||||
/* Vehicle */
|
/* Vehicle */
|
||||||
void OnInstall(Vehicle* vehicle);
|
void OnInstall(Vehicle* vehicle);
|
||||||
|
|||||||
@@ -825,4 +825,20 @@ void Eluna::OnPlayerDamage(Player* player, Unit* target, uint32& damage)
|
|||||||
}
|
}
|
||||||
|
|
||||||
CleanUpStack(3);
|
CleanUpStack(3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
bool Eluna::OnPlayerBeforeTeleport(Player* player, uint32 mapid, float x, float y, float z, float orientation, uint32 options, Unit* target)
|
||||||
|
{
|
||||||
|
START_HOOK_WITH_RETVAL(PLAYER_EVENT_ON_BEFORE_TELEPORT, true);
|
||||||
|
|
||||||
|
Push(player);
|
||||||
|
Push(mapid);
|
||||||
|
Push(x);
|
||||||
|
Push(y);
|
||||||
|
Push(z);
|
||||||
|
Push(orientation);
|
||||||
|
Push(options);
|
||||||
|
Push(target);
|
||||||
|
|
||||||
|
return CallAllFunctionsBool(PlayerEventBindings, key, true);
|
||||||
|
}
|
||||||
|
|||||||
@@ -785,6 +785,7 @@ namespace LuaGlobalFunctions
|
|||||||
* PLAYER_EVENT_ON_AURA_APPLY = 64, // (event, player, aura)
|
* PLAYER_EVENT_ON_AURA_APPLY = 64, // (event, player, aura)
|
||||||
* PLAYER_EVENT_ON_HEAL = 65, // (event, player, target, heal) - Can return new heal amount
|
* PLAYER_EVENT_ON_HEAL = 65, // (event, player, target, heal) - Can return new heal amount
|
||||||
* PLAYER_EVENT_ON_DAMAGE = 66, // (event, player, target, damage) - Can return new damage amount
|
* PLAYER_EVENT_ON_DAMAGE = 66, // (event, player, target, damage) - Can return new damage amount
|
||||||
|
* PLAYER_EVENT_ON_BEFORE_TELEPORT = 67, // (event, player, target, damage) - Can return new damage amount
|
||||||
* };
|
* };
|
||||||
* </pre>
|
* </pre>
|
||||||
*
|
*
|
||||||
|
|||||||
+106
-18
@@ -23,35 +23,37 @@ FORCE_PLAYERBOTS="${FORCE_PLAYERBOTS:-${ACORE_PLAYERBOTS_FORCE:-0}}"
|
|||||||
: "${ACORE_WORLD_CONSOLE_ENABLE:=0}"
|
: "${ACORE_WORLD_CONSOLE_ENABLE:=0}"
|
||||||
: "${ACORE_WORLD_DBC_ENFORCE_ITEM_ATTRIBUTES:=0}"
|
: "${ACORE_WORLD_DBC_ENFORCE_ITEM_ATTRIBUTES:=0}"
|
||||||
: "${ACORE_WORLD_INSTANCE_IGNORE_RAID:=1}"
|
: "${ACORE_WORLD_INSTANCE_IGNORE_RAID:=1}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_DAMAGE:=0.80}"
|
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_DAMAGE:=1.00}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_RARE_DAMAGE:=0.80}"
|
: "${ACORE_WORLD_RATE_CREATURE_RARE_DAMAGE:=1.00}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_DAMAGE:=0.75}"
|
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_DAMAGE:=1.00}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_WORLDBOSS_DAMAGE:=1}"
|
: "${ACORE_WORLD_RATE_CREATURE_WORLDBOSS_DAMAGE:=1}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_SPELL_DAMAGE:=0.80}"
|
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_SPELL_DAMAGE:=1.00}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_RARE_SPELL_DAMAGE:=0.80}"
|
: "${ACORE_WORLD_RATE_CREATURE_RARE_SPELL_DAMAGE:=1.00}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_SPELL_DAMAGE:=0.75}"
|
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_SPELL_DAMAGE:=1.00}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_WORLDBOSS_SPELL_DAMAGE:=1}"
|
: "${ACORE_WORLD_RATE_CREATURE_WORLDBOSS_SPELL_DAMAGE:=1}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_HP:=0.75}"
|
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_HP:=0.75}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_RARE_HP:=0.70}"
|
: "${ACORE_WORLD_RATE_CREATURE_RARE_HP:=0.70}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_HP:=0.65}"
|
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_HP:=0.65}"
|
||||||
: "${ACORE_WORLD_RATE_CREATURE_WORLDBOSS_HP:=1}"
|
: "${ACORE_WORLD_RATE_CREATURE_WORLDBOSS_HP:=1}"
|
||||||
|
|
||||||
: "${ACORE_AUTOBALANCE_MIN_PLAYERS:=1}"
|
: "${ACORE_AUTOBALANCE_MIN_PLAYERS:=2}"
|
||||||
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_HEROIC:=1}"
|
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_HEROIC:=2}"
|
||||||
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID:=1}"
|
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID:=1}"
|
||||||
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID_HEROIC:=1}"
|
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID_HEROIC:=1}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH:=1.30}"
|
: "${ACORE_AUTOBALANCE_LEVEL_SCALING_FLOOR_DUNGEONS:=1}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE:=1.10}"
|
: "${ACORE_AUTOBALANCE_LEVEL_SCALING_FLOOR_HEROIC_DUNGEONS:=1}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH:=1.40}"
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH:=1.55}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_HEALTH:=1.25}"
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE:=2.00}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_DAMAGE:=1.05}"
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH:=2.00}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_HEALTH:=1.35}"
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_HEALTH:=1.65}"
|
||||||
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_DAMAGE:=2.20}"
|
||||||
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_HEALTH:=2.20}"
|
||||||
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID:=0.75}"
|
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID:=0.75}"
|
||||||
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_BOSS_MODIFIER:=1.35}"
|
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_BOSS_MODIFIER:=1.35}"
|
||||||
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC:=0.80}"
|
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC:=0.80}"
|
||||||
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC_BOSS_MODIFIER:=1.45}"
|
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC_BOSS_MODIFIER:=1.45}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_DAMAGE:=1.00}"
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_DAMAGE:=2.50}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_DAMAGE:=0.90}"
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_DAMAGE:=2.80}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_GLOBAL:=0.85}"
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_GLOBAL:=0.85}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_DAMAGE:=0.75}"
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_DAMAGE:=0.75}"
|
||||||
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_BOSS_HEALTH:=0.70}"
|
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_BOSS_HEALTH:=0.70}"
|
||||||
@@ -401,14 +403,21 @@ AutoBalance.MinPlayers.Heroic = ${ACORE_AUTOBALANCE_MIN_PLAYERS_HEROIC}
|
|||||||
AutoBalance.MinPlayers.Raid = ${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID}
|
AutoBalance.MinPlayers.Raid = ${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID}
|
||||||
AutoBalance.MinPlayers.RaidHeroic = ${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID_HEROIC}
|
AutoBalance.MinPlayers.RaidHeroic = ${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID_HEROIC}
|
||||||
|
|
||||||
|
# Do not downscale dungeon creatures far below the highest player. The module
|
||||||
|
# default of 5 made level 38 Scarlet Monastery mobs become level 29 for a
|
||||||
|
# level 32 player, reducing their damage a second time after player scaling.
|
||||||
|
AutoBalance.LevelScaling.DynamicLevel.Floor.Dungeons = ${ACORE_AUTOBALANCE_LEVEL_SCALING_FLOOR_DUNGEONS}
|
||||||
|
AutoBalance.LevelScaling.DynamicLevel.Floor.HeroicDungeons = ${ACORE_AUTOBALANCE_LEVEL_SCALING_FLOOR_HEROIC_DUNGEONS}
|
||||||
|
|
||||||
# So raids scale down harder at low player counts.
|
# So raids scale down harder at low player counts.
|
||||||
AutoBalance.InflectionPointRaid = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID}
|
AutoBalance.InflectionPointRaid = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID}
|
||||||
AutoBalance.InflectionPointRaid.BossModifier = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_BOSS_MODIFIER}
|
AutoBalance.InflectionPointRaid.BossModifier = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_BOSS_MODIFIER}
|
||||||
AutoBalance.InflectionPointRaidHeroic = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC}
|
AutoBalance.InflectionPointRaidHeroic = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC}
|
||||||
AutoBalance.InflectionPointRaidHeroic.BossModifier = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC_BOSS_MODIFIER}
|
AutoBalance.InflectionPointRaidHeroic.BossModifier = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC_BOSS_MODIFIER}
|
||||||
|
|
||||||
# Moderate solo profile for 5-man dungeons: longer fights with a controlled
|
# Medium solo profile for 5-man dungeons. A solo player is scaled against a
|
||||||
# damage increase, while keeping one-player scaling enabled.
|
# two-player floor so large trash pulls are dangerous, while bosses receive
|
||||||
|
# extra health and damage to remain meaningful without requiring a full group.
|
||||||
AutoBalance.StatModifier.Health = ${ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH}
|
AutoBalance.StatModifier.Health = ${ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH}
|
||||||
AutoBalance.StatModifier.Damage = ${ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE}
|
AutoBalance.StatModifier.Damage = ${ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE}
|
||||||
AutoBalance.StatModifier.Boss.Health = ${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH}
|
AutoBalance.StatModifier.Boss.Health = ${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH}
|
||||||
@@ -472,6 +481,84 @@ DynamicQuestXP.Level_70_plus = ${ACORE_DYNAMIC_QUEST_XP_LEVEL_70_PLUS}
|
|||||||
EOF
|
EOF
|
||||||
}
|
}
|
||||||
|
|
||||||
|
write_account_bound_conf() {
|
||||||
|
write_file "$MODULES_ETC_DIR/AccountBound.conf.dist" <<EOF
|
||||||
|
# Managed by ./setup-modules.sh
|
||||||
|
AccountBound.Achievements.Enable = 0
|
||||||
|
AccountBound.Achievements.StartupBackfill = 1
|
||||||
|
AccountBound.Achievements.SameFactionOnly = 1
|
||||||
|
AccountBound.Achievements.SyncOnCreate = 1
|
||||||
|
AccountBound.Achievements.SyncRealmFirst = 0
|
||||||
|
AccountBound.Achievements.SyncHidden = 0
|
||||||
|
AccountBound.Achievements.ConvertFactionSpecific = 1
|
||||||
|
AccountBound.Achievements.SyncUnpairedFactionSpecific = 0
|
||||||
|
AccountBound.Achievements.AllowList = all
|
||||||
|
AccountBound.Achievements.BlockList = ""
|
||||||
|
AccountBound.Mounts.Enable = 1
|
||||||
|
AccountBound.Mounts.StartupBackfill = 1
|
||||||
|
AccountBound.Mounts.SyncOnCreate = 1
|
||||||
|
AccountBound.Mounts.RespectFactionRestrictions = 1
|
||||||
|
AccountBound.Mounts.ConvertFactionSpecific = 1
|
||||||
|
AccountBound.Mounts.RequireRiding = 1
|
||||||
|
AccountBound.Mounts.SyncClassMounts = 1
|
||||||
|
AccountBound.Mounts.RequireClass = 1
|
||||||
|
AccountBound.Mounts.ClassMountsSameFactionOnly = 1
|
||||||
|
AccountBound.Mounts.CleanupInvalid = 1
|
||||||
|
AccountBound.Mounts.BackfillOnRidingSkillChange = 1
|
||||||
|
AccountBound.Mounts.AllowList = all
|
||||||
|
AccountBound.Mounts.BlockList = ""
|
||||||
|
AccountBound.Pets.Enable = 1
|
||||||
|
AccountBound.Pets.StartupBackfill = 1
|
||||||
|
AccountBound.Pets.SyncOnCreate = 1
|
||||||
|
AccountBound.Pets.SameFactionOnly = 0
|
||||||
|
AccountBound.Pets.IncludeCompanionSkillLine = 1
|
||||||
|
AccountBound.Pets.IncludeMinipetSummons = 1
|
||||||
|
AccountBound.Pets.AllowList = all
|
||||||
|
AccountBound.Pets.BlockList = ""
|
||||||
|
AccountBound.Titles.Enable = 0
|
||||||
|
AccountBound.Titles.StartupBackfill = 1
|
||||||
|
AccountBound.Titles.SyncOnCreate = 1
|
||||||
|
AccountBound.Titles.SyncOnSave = 1
|
||||||
|
AccountBound.Titles.SyncRealmFirst = 0
|
||||||
|
AccountBound.Titles.ConvertFactionSpecific = 1
|
||||||
|
AccountBound.Titles.AllowList = all
|
||||||
|
AccountBound.Titles.BlockList = ""
|
||||||
|
AccountBound.Reputations.Enable = 0
|
||||||
|
AccountBound.Reputations.StartupBackfill = 1
|
||||||
|
AccountBound.Reputations.SyncOnCreate = 1
|
||||||
|
AccountBound.Reputations.SyncOnChange = 1
|
||||||
|
AccountBound.Reputations.SameFactionOnly = 1
|
||||||
|
AccountBound.Reputations.ConvertFactionSpecific = 1
|
||||||
|
AccountBound.Reputations.SyncUnpairedCrossFaction = 0
|
||||||
|
AccountBound.Reputations.AllowList = all
|
||||||
|
AccountBound.Reputations.BlockList = ""
|
||||||
|
AccountBound.Professions.Enable = 0
|
||||||
|
AccountBound.Professions.MaxPrimaryProfessions = 11
|
||||||
|
AccountBound.Professions.Primary.AllowList = all
|
||||||
|
AccountBound.Professions.Primary.BlockList = ""
|
||||||
|
AccountBound.Professions.Primary.CountOnlyAllowed = 1
|
||||||
|
AccountBound.Professions.Primary.EnforceAllowedList = 0
|
||||||
|
AccountBound.Professions.MaxSkillValue = 450
|
||||||
|
AccountBound.Professions.StartupBackfill = 1
|
||||||
|
AccountBound.Professions.SyncOnCreate = 1
|
||||||
|
AccountBound.Professions.SyncOnLearn = 1
|
||||||
|
AccountBound.Professions.IncludeSecondaryProfessions = 1
|
||||||
|
AccountBound.Professions.SyncSkillProgress = 1
|
||||||
|
AccountBound.Professions.SyncProfessionRanks = 1
|
||||||
|
AccountBound.Professions.SyncRecipes = 1
|
||||||
|
AccountBound.Professions.SyncSkillGrantedSpells = 1
|
||||||
|
AccountBound.Professions.RequireRecipeSkill = 1
|
||||||
|
AccountBound.Professions.SpellAllowList = ""
|
||||||
|
AccountBound.Professions.SpellBlockList = ""
|
||||||
|
AccountBound.Friends.Enable = 1
|
||||||
|
AccountBound.Friends.SyncOnCreate = 1
|
||||||
|
AccountBound.Friends.SyncOnlineChanges = 1
|
||||||
|
AccountBound.Friends.SameFactionOnly = 0
|
||||||
|
AccountBound.Friends.SyncIntervalSeconds = 3
|
||||||
|
AccountBound.Friends.StartupBackfill = 1
|
||||||
|
EOF
|
||||||
|
}
|
||||||
|
|
||||||
write_aoe_loot_conf() {
|
write_aoe_loot_conf() {
|
||||||
write_file "$MODULES_ETC_DIR/mod_aoe_loot.conf.dist" <<EOF
|
write_file "$MODULES_ETC_DIR/mod_aoe_loot.conf.dist" <<EOF
|
||||||
# Managed by ./setup-modules.sh
|
# Managed by ./setup-modules.sh
|
||||||
@@ -729,6 +816,7 @@ main() {
|
|||||||
write_gamemodes_conf
|
write_gamemodes_conf
|
||||||
write_playerbots_conf
|
write_playerbots_conf
|
||||||
write_playerbots_ai_conf
|
write_playerbots_ai_conf
|
||||||
|
write_account_bound_conf
|
||||||
if (( SKIP_SQL )); then
|
if (( SKIP_SQL )); then
|
||||||
log "local module SQL imports skipped"
|
log "local module SQL imports skipped"
|
||||||
else
|
else
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
-- Create Squire NPC
|
||||||
|
INSERT
|
||||||
|
INTO
|
||||||
|
acore_world.creature_template (entry,
|
||||||
|
difficulty_entry_1,
|
||||||
|
difficulty_entry_2,
|
||||||
|
difficulty_entry_3,
|
||||||
|
KillCredit1,
|
||||||
|
KillCredit2,
|
||||||
|
name,
|
||||||
|
subname,
|
||||||
|
IconName,
|
||||||
|
gossip_menu_id,
|
||||||
|
minlevel,
|
||||||
|
maxlevel,
|
||||||
|
`exp`,
|
||||||
|
faction,
|
||||||
|
npcflag,
|
||||||
|
speed_walk,
|
||||||
|
speed_run,
|
||||||
|
speed_swim,
|
||||||
|
speed_flight,
|
||||||
|
detection_range,
|
||||||
|
`rank`,
|
||||||
|
dmgschool,
|
||||||
|
DamageModifier,
|
||||||
|
BaseAttackTime,
|
||||||
|
RangeAttackTime,
|
||||||
|
BaseVariance,
|
||||||
|
RangeVariance,
|
||||||
|
unit_class,
|
||||||
|
unit_flags,
|
||||||
|
unit_flags2,
|
||||||
|
dynamicflags,
|
||||||
|
family,
|
||||||
|
`type`,
|
||||||
|
type_flags,
|
||||||
|
lootid,
|
||||||
|
pickpocketloot,
|
||||||
|
skinloot,
|
||||||
|
PetSpellDataId,
|
||||||
|
VehicleId,
|
||||||
|
mingold,
|
||||||
|
maxgold,
|
||||||
|
AIName,
|
||||||
|
MovementType,
|
||||||
|
HoverHeight,
|
||||||
|
HealthModifier,
|
||||||
|
ManaModifier,
|
||||||
|
ArmorModifier,
|
||||||
|
ExperienceModifier,
|
||||||
|
RacialLeader,
|
||||||
|
movementId,
|
||||||
|
RegenHealth,
|
||||||
|
flags_extra,
|
||||||
|
ScriptName,
|
||||||
|
VerifiedBuild)
|
||||||
|
VALUES
|
||||||
|
(9000001,
|
||||||
|
29831,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'Eluna Squire',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
81,
|
||||||
|
81,
|
||||||
|
2,
|
||||||
|
21,
|
||||||
|
69341185,
|
||||||
|
1.0,
|
||||||
|
1.42857,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
20.0,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
7.5,
|
||||||
|
2000,
|
||||||
|
2000,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
1,
|
||||||
|
64,
|
||||||
|
2048,
|
||||||
|
8,
|
||||||
|
0,
|
||||||
|
6,
|
||||||
|
72,
|
||||||
|
100003,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
1163,
|
||||||
|
1524,
|
||||||
|
'PassiveAI',
|
||||||
|
0,
|
||||||
|
1.0,
|
||||||
|
4.0,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
2.5,
|
||||||
|
0,
|
||||||
|
144,
|
||||||
|
1,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
12340) ON DUPLICATE KEY UPDATE
|
||||||
|
npcflag = VALUES(npcflag),
|
||||||
|
AIName = VALUES(AIName);
|
||||||
|
|
||||||
|
|
||||||
|
-- Add Squire localization
|
||||||
|
INSERT INTO
|
||||||
|
acore_world.creature_template_locale (entry,
|
||||||
|
locale,
|
||||||
|
Name,
|
||||||
|
Title,
|
||||||
|
VerifiedBuild)
|
||||||
|
VALUES
|
||||||
|
(9000001,
|
||||||
|
'ruRU',
|
||||||
|
'Оруженосец',
|
||||||
|
'',
|
||||||
|
12340) ON DUPLICATE KEY UPDATE entry = entry;
|
||||||
|
|
||||||
|
|
||||||
|
-- Add Squire model
|
||||||
|
INSERT
|
||||||
|
INTO
|
||||||
|
acore_world.creature_template_model (CreatureID,
|
||||||
|
Idx,
|
||||||
|
CreatureDisplayID,
|
||||||
|
DisplayScale,
|
||||||
|
Probability,
|
||||||
|
VerifiedBuild)
|
||||||
|
VALUES
|
||||||
|
(9000001,
|
||||||
|
0,
|
||||||
|
16927,
|
||||||
|
1.0,
|
||||||
|
1.0,
|
||||||
|
12340) ON
|
||||||
|
DUPLICATE KEY
|
||||||
|
UPDATE
|
||||||
|
CreatureID = CreatureID;
|
||||||
|
|
||||||
|
-- Add Squire dialogue text
|
||||||
|
INSERT
|
||||||
|
INTO
|
||||||
|
acore_world.npc_text (ID,
|
||||||
|
text0_0,
|
||||||
|
text0_1,
|
||||||
|
BroadcastTextID0,
|
||||||
|
lang0,
|
||||||
|
Probability0,
|
||||||
|
em0_0,
|
||||||
|
em0_1,
|
||||||
|
em0_2,
|
||||||
|
em0_3,
|
||||||
|
em0_4,
|
||||||
|
em0_5,
|
||||||
|
text1_0,
|
||||||
|
text1_1,
|
||||||
|
BroadcastTextID1,
|
||||||
|
lang1,
|
||||||
|
Probability1,
|
||||||
|
em1_0,
|
||||||
|
em1_1,
|
||||||
|
em1_2,
|
||||||
|
em1_3,
|
||||||
|
em1_4,
|
||||||
|
em1_5,
|
||||||
|
text2_0,
|
||||||
|
text2_1,
|
||||||
|
BroadcastTextID2,
|
||||||
|
lang2,
|
||||||
|
Probability2,
|
||||||
|
em2_0,
|
||||||
|
em2_1,
|
||||||
|
em2_2,
|
||||||
|
em2_3,
|
||||||
|
em2_4,
|
||||||
|
em2_5,
|
||||||
|
text3_0,
|
||||||
|
text3_1,
|
||||||
|
BroadcastTextID3,
|
||||||
|
lang3,
|
||||||
|
Probability3,
|
||||||
|
em3_0,
|
||||||
|
em3_1,
|
||||||
|
em3_2,
|
||||||
|
em3_3,
|
||||||
|
em3_4,
|
||||||
|
em3_5,
|
||||||
|
text4_0,
|
||||||
|
text4_1,
|
||||||
|
BroadcastTextID4,
|
||||||
|
lang4,
|
||||||
|
Probability4,
|
||||||
|
em4_0,
|
||||||
|
em4_1,
|
||||||
|
em4_2,
|
||||||
|
em4_3,
|
||||||
|
em4_4,
|
||||||
|
em4_5,
|
||||||
|
text5_0,
|
||||||
|
text5_1,
|
||||||
|
BroadcastTextID5,
|
||||||
|
lang5,
|
||||||
|
Probability5,
|
||||||
|
em5_0,
|
||||||
|
em5_1,
|
||||||
|
em5_2,
|
||||||
|
em5_3,
|
||||||
|
em5_4,
|
||||||
|
em5_5,
|
||||||
|
text6_0,
|
||||||
|
text6_1,
|
||||||
|
BroadcastTextID6,
|
||||||
|
lang6,
|
||||||
|
Probability6,
|
||||||
|
em6_0,
|
||||||
|
em6_1,
|
||||||
|
em6_2,
|
||||||
|
em6_3,
|
||||||
|
em6_4,
|
||||||
|
em6_5,
|
||||||
|
text7_0,
|
||||||
|
text7_1,
|
||||||
|
BroadcastTextID7,
|
||||||
|
lang7,
|
||||||
|
Probability7,
|
||||||
|
em7_0,
|
||||||
|
em7_1,
|
||||||
|
em7_2,
|
||||||
|
em7_3,
|
||||||
|
em7_4,
|
||||||
|
em7_5,
|
||||||
|
VerifiedBuild)
|
||||||
|
VALUES
|
||||||
|
(10000000,
|
||||||
|
'',
|
||||||
|
'By the grace of the stars, I have been bound to your side, $n. This ancient pile of bones is ready to serve you.',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0.0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
0) ON DUPLICATE KEY UPDATE ID = ID;
|
||||||
|
|
||||||
|
-- Add squire dialogue text locale
|
||||||
|
INSERT
|
||||||
|
INTO
|
||||||
|
acore_world.npc_text_locale (ID,
|
||||||
|
Locale,
|
||||||
|
Text0_0,
|
||||||
|
Text0_1,
|
||||||
|
Text1_0,
|
||||||
|
Text1_1,
|
||||||
|
Text2_0,
|
||||||
|
Text2_1,
|
||||||
|
Text3_0,
|
||||||
|
Text3_1,
|
||||||
|
Text4_0,
|
||||||
|
Text4_1,
|
||||||
|
Text5_0,
|
||||||
|
Text5_1,
|
||||||
|
Text6_0,
|
||||||
|
Text6_1,
|
||||||
|
Text7_0,
|
||||||
|
Text7_1)
|
||||||
|
VALUES
|
||||||
|
(10000000,
|
||||||
|
'ruRU',
|
||||||
|
'',
|
||||||
|
'Милостью высших сил я был приставлен к тебе, $n. Эти древние кости готовы служить. Чего желаешь?',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'',
|
||||||
|
'') ON DUPLICATE KEY UPDATE ID = ID;
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
-- --------------------------------------------------------
|
||||||
|
-- Host: 192.168.0.167
|
||||||
|
-- Server-Version: 8.3.0 - MySQL Community Server - GPL
|
||||||
|
-- Server-Betriebssystem: Linux
|
||||||
|
-- HeidiSQL Version: 12.8.0.6908
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET NAMES utf8 */;
|
||||||
|
/*!50503 SET NAMES utf8mb4 */;
|
||||||
|
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||||
|
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||||
|
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||||
|
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||||
|
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||||
|
|
||||||
|
-- Exportiere Struktur von Tabelle acore_auth.account_transmog
|
||||||
|
CREATE TABLE IF NOT EXISTS `account_transmog` (
|
||||||
|
`account_id` int unsigned NOT NULL,
|
||||||
|
`unlocked_item_id` int unsigned NOT NULL,
|
||||||
|
`inventory_type` int unsigned DEFAULT NULL,
|
||||||
|
`inventory_subtype` int unsigned DEFAULT NULL,
|
||||||
|
`display_id` int unsigned NOT NULL,
|
||||||
|
`item_name` varchar(255) COLLATE utf8mb4_general_ci DEFAULT NULL,
|
||||||
|
UNIQUE KEY `account_item_id` (`account_id`,`unlocked_item_id`) USING BTREE,
|
||||||
|
UNIQUE KEY `account_display_id` (`account_id`,`display_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- Daten-Export vom Benutzer nicht ausgewählt
|
||||||
|
|
||||||
|
/*!40103 SET TIME_ZONE=IFNULL(@OLD_TIME_ZONE, 'system') */;
|
||||||
|
/*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */;
|
||||||
|
/*!40014 SET FOREIGN_KEY_CHECKS=IFNULL(@OLD_FOREIGN_KEY_CHECKS, 1) */;
|
||||||
|
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40111 SET SQL_NOTES=IFNULL(@OLD_SQL_NOTES, 1) */;
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
-- --------------------------------------------------------
|
||||||
|
-- Host: 192.168.0.167
|
||||||
|
-- Server-Version: 8.3.0 - MySQL Community Server - GPL
|
||||||
|
-- Server-Betriebssystem: Linux
|
||||||
|
-- HeidiSQL Version: 12.8.0.6908
|
||||||
|
-- --------------------------------------------------------
|
||||||
|
|
||||||
|
/*!40101 SET @OLD_CHARACTER_SET_CLIENT=@@CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40101 SET NAMES utf8 */;
|
||||||
|
/*!50503 SET NAMES utf8mb4 */;
|
||||||
|
/*!40103 SET @OLD_TIME_ZONE=@@TIME_ZONE */;
|
||||||
|
/*!40103 SET TIME_ZONE='+00:00' */;
|
||||||
|
/*!40014 SET @OLD_FOREIGN_KEY_CHECKS=@@FOREIGN_KEY_CHECKS, FOREIGN_KEY_CHECKS=0 */;
|
||||||
|
/*!40101 SET @OLD_SQL_MODE=@@SQL_MODE, SQL_MODE='NO_AUTO_VALUE_ON_ZERO' */;
|
||||||
|
/*!40111 SET @OLD_SQL_NOTES=@@SQL_NOTES, SQL_NOTES=0 */;
|
||||||
|
|
||||||
|
-- Exportiere Struktur von Tabelle acore_characters.character_transmog
|
||||||
|
CREATE TABLE IF NOT EXISTS `character_transmog` (
|
||||||
|
`player_guid` int unsigned DEFAULT NULL,
|
||||||
|
`slot` varchar(3) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci NOT NULL DEFAULT '',
|
||||||
|
`item` int unsigned DEFAULT NULL,
|
||||||
|
`real_item` int unsigned DEFAULT NULL,
|
||||||
|
UNIQUE KEY `player_and_slot` (`player_guid`,`slot`) USING BTREE,
|
||||||
|
KEY `player_and_slot_key` (`player_guid`,`slot`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
|
||||||
|
|
||||||
|
-- Daten-Export vom Benutzer nicht ausgewählt
|
||||||
|
|
||||||
|
/*!40103 SET TIME_ZONE=IFNULL(@OLD_TIME_ZONE, 'system') */;
|
||||||
|
/*!40101 SET SQL_MODE=IFNULL(@OLD_SQL_MODE, '') */;
|
||||||
|
/*!40014 SET FOREIGN_KEY_CHECKS=IFNULL(@OLD_FOREIGN_KEY_CHECKS, 1) */;
|
||||||
|
/*!40101 SET CHARACTER_SET_CLIENT=@OLD_CHARACTER_SET_CLIENT */;
|
||||||
|
/*!40111 SET SQL_NOTES=IFNULL(@OLD_SQL_NOTES, 1) */;
|
||||||
@@ -183,6 +183,7 @@ bool AuthSession::Update()
|
|||||||
return false;
|
return false;
|
||||||
|
|
||||||
_queryProcessor.ProcessReadyCallbacks();
|
_queryProcessor.ProcessReadyCallbacks();
|
||||||
|
_transactionProcessor.ProcessReadyCallbacks();
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -309,7 +310,9 @@ bool AuthSession::HandleLogonChallenge()
|
|||||||
// Get the account details from the account table
|
// Get the account details from the account table
|
||||||
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_SEL_LOGONCHALLENGE);
|
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_SEL_LOGONCHALLENGE);
|
||||||
stmt->SetData(0, GetRemoteIpAddress().to_string());
|
stmt->SetData(0, GetRemoteIpAddress().to_string());
|
||||||
stmt->SetData(1, login);
|
stmt->SetData(1, GetRemoteIpAddress().to_string());
|
||||||
|
stmt->SetData(2, _build);
|
||||||
|
stmt->SetData(3, login);
|
||||||
|
|
||||||
_queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt).WithPreparedCallback(std::bind(&AuthSession::LogonChallengeCallback, this, std::placeholders::_1)));
|
_queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt).WithPreparedCallback(std::bind(&AuthSession::LogonChallengeCallback, this, std::placeholders::_1)));
|
||||||
return true;
|
return true;
|
||||||
@@ -387,8 +390,21 @@ void AuthSession::LogonChallengeCallback(PreparedQueryResult result)
|
|||||||
|
|
||||||
uint8 securityFlags = 0;
|
uint8 securityFlags = 0;
|
||||||
|
|
||||||
// Check if a TOTP token is needed
|
_isDeveloperLogin = fields[15].Get<bool>();
|
||||||
if (!fields[12].IsNull())
|
_launcherTicketGeneration.reset();
|
||||||
|
|
||||||
|
if (!_isDeveloperLogin && (fields[16].IsNull() || fields[17].IsNull() || fields[18].IsNull()))
|
||||||
|
{
|
||||||
|
pkt << uint8(WOW_FAIL_UNKNOWN_ACCOUNT);
|
||||||
|
SendPacket(pkt);
|
||||||
|
LOG_INFO("server.authserver", "'{}:{}' [AuthChallenge] launcher ticket required for account id {}",
|
||||||
|
ipAddress, port, _accountInfo.Id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Launcher authentication completes MFA before ticket issuance. Only the
|
||||||
|
// explicitly allowlisted developer flow may request the legacy TOTP UI.
|
||||||
|
if (_isDeveloperLogin && !fields[12].IsNull())
|
||||||
{
|
{
|
||||||
securityFlags = 4;
|
securityFlags = 4;
|
||||||
_totpSecret = fields[12].Get<Binary>();
|
_totpSecret = fields[12].Get<Binary>();
|
||||||
@@ -406,9 +422,19 @@ void AuthSession::LogonChallengeCallback(PreparedQueryResult result)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_srp6.emplace(_accountInfo.Login,
|
if (_isDeveloperLogin)
|
||||||
fields[13].Get<Binary, Acore::Crypto::SRP6::SALT_LENGTH>(),
|
{
|
||||||
fields[14].Get<Binary, Acore::Crypto::SRP6::VERIFIER_LENGTH>());
|
_srp6.emplace(_accountInfo.Login,
|
||||||
|
fields[13].Get<Binary, Acore::Crypto::SRP6::SALT_LENGTH>(),
|
||||||
|
fields[14].Get<Binary, Acore::Crypto::SRP6::VERIFIER_LENGTH>());
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
_launcherTicketGeneration = fields[16].Get<Binary, 16>();
|
||||||
|
_srp6.emplace(_accountInfo.Login,
|
||||||
|
fields[17].Get<Binary, Acore::Crypto::SRP6::SALT_LENGTH>(),
|
||||||
|
fields[18].Get<Binary, Acore::Crypto::SRP6::VERIFIER_LENGTH>());
|
||||||
|
}
|
||||||
|
|
||||||
// Fill the response packet with the result
|
// Fill the response packet with the result
|
||||||
if (AuthHelper::IsAcceptedClientBuild(_build))
|
if (AuthHelper::IsAcceptedClientBuild(_build))
|
||||||
@@ -515,45 +541,77 @@ bool AuthSession::HandleLogonProof()
|
|||||||
// No SQL injection (escaped user name) and IP address as received by socket
|
// No SQL injection (escaped user name) and IP address as received by socket
|
||||||
|
|
||||||
std::string address = sConfigMgr->GetOption<bool>("AllowLoggingIPAddressesInDatabase", true, true) ? GetRemoteIpAddress().to_string() : "0.0.0.0";
|
std::string address = sConfigMgr->GetOption<bool>("AllowLoggingIPAddressesInDatabase", true, true) ? GetRemoteIpAddress().to_string() : "0.0.0.0";
|
||||||
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LOGONPROOF);
|
Acore::Crypto::SHA1::Digest serverProof = Acore::Crypto::SRP6::GetSessionVerifier(logonProof->A, logonProof->clientM, _sessionKey);
|
||||||
stmt->SetData(0, _sessionKey);
|
|
||||||
stmt->SetData(1, address);
|
if (_isDeveloperLogin)
|
||||||
stmt->SetData(2, GetLocaleByName(_localizationName));
|
|
||||||
stmt->SetData(3, _os);
|
|
||||||
stmt->SetData(4, _accountInfo.Login);
|
|
||||||
_queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt)
|
|
||||||
.WithPreparedCallback([this, M2 = Acore::Crypto::SRP6::GetSessionVerifier(logonProof->A, logonProof->clientM, _sessionKey)](PreparedQueryResult const&)
|
|
||||||
{
|
{
|
||||||
// Finish SRP6 and send the final result to the client
|
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LOGONPROOF);
|
||||||
ByteBuffer packet;
|
stmt->SetData(0, _sessionKey);
|
||||||
if (_expversion & POST_BC_EXP_FLAG) // 2.x and 3.x clients
|
stmt->SetData(1, address);
|
||||||
|
stmt->SetData(2, GetLocaleByName(_localizationName));
|
||||||
|
stmt->SetData(3, _os);
|
||||||
|
stmt->SetData(4, _accountInfo.Login);
|
||||||
|
_queryProcessor.AddCallback(LoginDatabase.AsyncQuery(stmt)
|
||||||
|
.WithPreparedCallback([this, serverProof](PreparedQueryResult const&)
|
||||||
{
|
{
|
||||||
sAuthLogonProof_S proof;
|
SendLogonProofSuccess(serverProof);
|
||||||
proof.M2 = M2;
|
}));
|
||||||
proof.cmd = AUTH_LOGON_PROOF;
|
}
|
||||||
proof.error = 0;
|
else
|
||||||
proof.AccountFlags = _accountInfo.Flags;
|
{
|
||||||
proof.SurveyId = 0;
|
ASSERT(_launcherTicketGeneration);
|
||||||
proof.LoginFlags = 0; // 0x1 = has account message
|
std::array<uint8, 16> claimNonce = Acore::Crypto::GetRandomBytes<16>();
|
||||||
|
|
||||||
packet.resize(sizeof(proof));
|
LoginDatabaseTransaction transaction = LoginDatabase.BeginTransaction();
|
||||||
std::memcpy(packet.contents(), &proof, sizeof(proof));
|
|
||||||
}
|
LoginDatabasePreparedStatement* consume = LoginDatabase.GetPreparedStatement(LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION);
|
||||||
else
|
consume->SetData(0, claimNonce);
|
||||||
|
consume->SetData(1, _accountInfo.Id);
|
||||||
|
consume->SetData(2, *_launcherTicketGeneration);
|
||||||
|
transaction->Append(consume);
|
||||||
|
|
||||||
|
LoginDatabasePreparedStatement* remove = LoginDatabase.GetPreparedStatement(LOGIN_DEL_LAUNCHER_TICKET);
|
||||||
|
remove->SetData(0, _accountInfo.Id);
|
||||||
|
remove->SetData(1, *_launcherTicketGeneration);
|
||||||
|
transaction->Append(remove);
|
||||||
|
|
||||||
|
LoginDatabasePreparedStatement* update = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LAUNCHER_LOGONPROOF);
|
||||||
|
update->SetData(0, _sessionKey);
|
||||||
|
update->SetData(1, address);
|
||||||
|
update->SetData(2, GetLocaleByName(_localizationName));
|
||||||
|
update->SetData(3, _os);
|
||||||
|
update->SetData(4, _accountInfo.Id);
|
||||||
|
update->SetData(5, *_launcherTicketGeneration);
|
||||||
|
update->SetData(6, claimNonce);
|
||||||
|
transaction->Append(update);
|
||||||
|
|
||||||
|
std::array<uint8, 16> generation = *_launcherTicketGeneration;
|
||||||
|
_transactionProcessor.AddCallback(LoginDatabase.AsyncCommitTransaction(transaction)).AfterComplete(
|
||||||
|
[this, serverProof, generation, claimNonce](bool success)
|
||||||
{
|
{
|
||||||
sAuthLogonProof_S_Old proof;
|
if (!success)
|
||||||
proof.M2 = M2;
|
{
|
||||||
proof.cmd = AUTH_LOGON_PROOF;
|
SendLogonProofFailure();
|
||||||
proof.error = 0;
|
return;
|
||||||
proof.unk2 = 0x00;
|
}
|
||||||
|
|
||||||
packet.resize(sizeof(proof));
|
LoginDatabasePreparedStatement* claim = LoginDatabase.GetPreparedStatement(LOGIN_SEL_LAUNCHER_TICKET_CLAIM);
|
||||||
std::memcpy(packet.contents(), &proof, sizeof(proof));
|
claim->SetData(0, _accountInfo.Id);
|
||||||
}
|
claim->SetData(1, generation);
|
||||||
|
claim->SetData(2, claimNonce);
|
||||||
|
_queryProcessor.AddCallback(LoginDatabase.AsyncQuery(claim)
|
||||||
|
.WithPreparedCallback([this, serverProof](PreparedQueryResult result)
|
||||||
|
{
|
||||||
|
if (!result)
|
||||||
|
{
|
||||||
|
SendLogonProofFailure();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
SendPacket(packet);
|
SendLogonProofSuccess(serverProof);
|
||||||
_status = STATUS_AUTHED;
|
}));
|
||||||
}));
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
@@ -618,6 +676,47 @@ bool AuthSession::HandleLogonProof()
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void AuthSession::SendLogonProofFailure()
|
||||||
|
{
|
||||||
|
ByteBuffer packet;
|
||||||
|
packet << uint8(AUTH_LOGON_PROOF);
|
||||||
|
packet << uint8(WOW_FAIL_UNKNOWN_ACCOUNT);
|
||||||
|
packet << uint16(0);
|
||||||
|
SendPacket(packet);
|
||||||
|
}
|
||||||
|
|
||||||
|
void AuthSession::SendLogonProofSuccess(Acore::Crypto::SHA1::Digest const& serverProof)
|
||||||
|
{
|
||||||
|
ByteBuffer packet;
|
||||||
|
if (_expversion & POST_BC_EXP_FLAG)
|
||||||
|
{
|
||||||
|
sAuthLogonProof_S proof;
|
||||||
|
proof.M2 = serverProof;
|
||||||
|
proof.cmd = AUTH_LOGON_PROOF;
|
||||||
|
proof.error = 0;
|
||||||
|
proof.AccountFlags = _accountInfo.Flags;
|
||||||
|
proof.SurveyId = 0;
|
||||||
|
proof.LoginFlags = 0;
|
||||||
|
|
||||||
|
packet.resize(sizeof(proof));
|
||||||
|
std::memcpy(packet.contents(), &proof, sizeof(proof));
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
sAuthLogonProof_S_Old proof;
|
||||||
|
proof.M2 = serverProof;
|
||||||
|
proof.cmd = AUTH_LOGON_PROOF;
|
||||||
|
proof.error = 0;
|
||||||
|
proof.unk2 = 0x00;
|
||||||
|
|
||||||
|
packet.resize(sizeof(proof));
|
||||||
|
std::memcpy(packet.contents(), &proof, sizeof(proof));
|
||||||
|
}
|
||||||
|
|
||||||
|
SendPacket(packet);
|
||||||
|
_status = STATUS_AUTHED;
|
||||||
|
}
|
||||||
|
|
||||||
bool AuthSession::HandleReconnectChallenge()
|
bool AuthSession::HandleReconnectChallenge()
|
||||||
{
|
{
|
||||||
_status = STATUS_CLOSED;
|
_status = STATUS_CLOSED;
|
||||||
|
|||||||
@@ -27,6 +27,7 @@
|
|||||||
#include "QueryResult.h"
|
#include "QueryResult.h"
|
||||||
#include "SRP6.h"
|
#include "SRP6.h"
|
||||||
#include "Socket.h"
|
#include "Socket.h"
|
||||||
|
#include "Transaction.h"
|
||||||
#include <boost/asio/ip/tcp.hpp>
|
#include <boost/asio/ip/tcp.hpp>
|
||||||
|
|
||||||
using boost::asio::ip::tcp;
|
using boost::asio::ip::tcp;
|
||||||
@@ -89,10 +90,14 @@ private:
|
|||||||
void LogonChallengeCallback(PreparedQueryResult result);
|
void LogonChallengeCallback(PreparedQueryResult result);
|
||||||
void ReconnectChallengeCallback(PreparedQueryResult result);
|
void ReconnectChallengeCallback(PreparedQueryResult result);
|
||||||
void RealmListCallback(PreparedQueryResult result);
|
void RealmListCallback(PreparedQueryResult result);
|
||||||
|
void SendLogonProofFailure();
|
||||||
|
void SendLogonProofSuccess(Acore::Crypto::SHA1::Digest const& serverProof);
|
||||||
|
|
||||||
bool VerifyVersion(uint8 const* a, int32 aLength, Acore::Crypto::SHA1::Digest const& versionProof, bool isReconnect);
|
bool VerifyVersion(uint8 const* a, int32 aLength, Acore::Crypto::SHA1::Digest const& versionProof, bool isReconnect);
|
||||||
|
|
||||||
Optional<Acore::Crypto::SRP6> _srp6;
|
Optional<Acore::Crypto::SRP6> _srp6;
|
||||||
|
Optional<std::array<uint8, 16>> _launcherTicketGeneration;
|
||||||
|
bool _isDeveloperLogin = false;
|
||||||
SessionKey _sessionKey = {};
|
SessionKey _sessionKey = {};
|
||||||
std::array<uint8, 16> _reconnectProof = {};
|
std::array<uint8, 16> _reconnectProof = {};
|
||||||
|
|
||||||
@@ -106,6 +111,7 @@ private:
|
|||||||
uint8 _expversion;
|
uint8 _expversion;
|
||||||
|
|
||||||
QueryCallbackProcessor _queryProcessor;
|
QueryCallbackProcessor _queryProcessor;
|
||||||
|
AsyncCallbackProcessor<TransactionCallback> _transactionProcessor;
|
||||||
};
|
};
|
||||||
|
|
||||||
#pragma pack(push, 1)
|
#pragma pack(push, 1)
|
||||||
|
|||||||
@@ -27,11 +27,14 @@ void LoginDatabaseConnection::DoPrepareStatements()
|
|||||||
"SELECT a.id, a.username, a.locked, a.lock_country, a.last_ip, a.Flags, a.failed_logins, "
|
"SELECT a.id, a.username, a.locked, a.lock_country, a.last_ip, a.Flags, a.failed_logins, "
|
||||||
"ab.unbandate > UNIX_TIMESTAMP() OR ab.unbandate = ab.bandate, ab.unbandate = ab.bandate, "
|
"ab.unbandate > UNIX_TIMESTAMP() OR ab.unbandate = ab.bandate, ab.unbandate = ab.bandate, "
|
||||||
"ipb.unbandate > UNIX_TIMESTAMP() OR ipb.unbandate = ipb.bandate, ipb.unbandate = ipb.bandate, "
|
"ipb.unbandate > UNIX_TIMESTAMP() OR ipb.unbandate = ipb.bandate, ipb.unbandate = ipb.bandate, "
|
||||||
"aa.gmlevel, a.totp_secret, a.salt, a.verifier "
|
"aa.gmlevel, a.totp_secret, a.salt, a.verifier, "
|
||||||
|
"lda.account_id IS NOT NULL, lt.generation_id, lt.srp_salt, lt.srp_verifier "
|
||||||
"FROM account a "
|
"FROM account a "
|
||||||
"LEFT JOIN account_access aa ON a.id = aa.id "
|
"LEFT JOIN account_access aa ON a.id = aa.id "
|
||||||
"LEFT JOIN account_banned ab ON ab.id = a.id AND ab.active = 1 "
|
"LEFT JOIN account_banned ab ON ab.id = a.id AND ab.active = 1 "
|
||||||
"LEFT JOIN ip_banned ipb ON ipb.ip = ? "
|
"LEFT JOIN ip_banned ipb ON ipb.ip = ? "
|
||||||
|
"LEFT JOIN launcher_dev_account lda ON lda.account_id = a.id AND lda.allowed_ip = ? AND lda.enabled = 1 "
|
||||||
|
"LEFT JOIN launcher_ticket lt ON lt.account_id = a.id AND lt.expires_at > UTC_TIMESTAMP(6) AND lt.client_build = ? "
|
||||||
"WHERE a.username = ?", CONNECTION_ASYNC);
|
"WHERE a.username = ?", CONNECTION_ASYNC);
|
||||||
PrepareStatement(LOGIN_SEL_RECONNECTCHALLENGE,
|
PrepareStatement(LOGIN_SEL_RECONNECTCHALLENGE,
|
||||||
"SELECT a.id, a.username, a.locked, a.lock_country, a.last_ip, a.Flags, a.failed_logins, "
|
"SELECT a.id, a.username, a.locked, a.lock_country, a.last_ip, a.Flags, a.failed_logins, "
|
||||||
@@ -42,11 +45,11 @@ void LoginDatabaseConnection::DoPrepareStatements()
|
|||||||
"LEFT JOIN account_access aa ON a.id = aa.id "
|
"LEFT JOIN account_access aa ON a.id = aa.id "
|
||||||
"LEFT JOIN account_banned ab ON ab.id = a.id AND ab.active = 1 "
|
"LEFT JOIN account_banned ab ON ab.id = a.id AND ab.active = 1 "
|
||||||
"LEFT JOIN ip_banned ipb ON ipb.ip = ? "
|
"LEFT JOIN ip_banned ipb ON ipb.ip = ? "
|
||||||
"WHERE a.username = ? AND a.session_key IS NOT NULL", CONNECTION_ASYNC);
|
"WHERE a.username = ? AND a.session_key IS NOT NULL AND a.session_auth IN ('launcher', 'dev')", CONNECTION_ASYNC);
|
||||||
PrepareStatement(LOGIN_SEL_ACCOUNT_INFO_BY_NAME, "SELECT a.id, a.session_key, a.last_ip, a.locked, a.lock_country, a.expansion, a.Flags, a.mutetime, a.locale, a.recruiter, a.os, a.totaltime, "
|
PrepareStatement(LOGIN_SEL_ACCOUNT_INFO_BY_NAME, "SELECT a.id, a.session_key, a.last_ip, a.locked, a.lock_country, a.expansion, a.Flags, a.mutetime, a.locale, a.recruiter, a.os, a.totaltime, "
|
||||||
"aa.gmlevel, ab.unbandate > UNIX_TIMESTAMP() OR ab.unbandate = ab.bandate, r.id FROM account a LEFT JOIN account_access aa ON a.id = aa.id AND aa.RealmID IN (-1, ?) "
|
"aa.gmlevel, ab.unbandate > UNIX_TIMESTAMP() OR ab.unbandate = ab.bandate, r.id FROM account a LEFT JOIN account_access aa ON a.id = aa.id AND aa.RealmID IN (-1, ?) "
|
||||||
"LEFT JOIN account_banned ab ON a.id = ab.id AND ab.active = 1 LEFT JOIN account r ON a.id = r.recruiter WHERE a.username = ? "
|
"LEFT JOIN account_banned ab ON a.id = ab.id AND ab.active = 1 LEFT JOIN account r ON a.id = r.recruiter WHERE a.username = ? "
|
||||||
"AND a.session_key IS NOT NULL ORDER BY aa.RealmID DESC LIMIT 1", CONNECTION_ASYNC);
|
"AND a.session_key IS NOT NULL AND a.session_auth IN ('launcher', 'dev') ORDER BY aa.RealmID DESC LIMIT 1", CONNECTION_ASYNC);
|
||||||
PrepareStatement(LOGIN_SEL_IP_INFO, "SELECT unbandate > UNIX_TIMESTAMP() OR unbandate = bandate AS banned, NULL as country FROM ip_banned WHERE ip = ?", CONNECTION_ASYNC);
|
PrepareStatement(LOGIN_SEL_IP_INFO, "SELECT unbandate > UNIX_TIMESTAMP() OR unbandate = bandate AS banned, NULL as country FROM ip_banned WHERE ip = ?", CONNECTION_ASYNC);
|
||||||
PrepareStatement(LOGIN_SEL_REALMLIST, "SELECT id, name, address, localAddress, localSubnetMask, port, icon, flag, timezone, allowedSecurityLevel, population, gamebuild FROM realmlist WHERE flag <> 3 ORDER BY name", CONNECTION_SYNCH);
|
PrepareStatement(LOGIN_SEL_REALMLIST, "SELECT id, name, address, localAddress, localSubnetMask, port, icon, flag, timezone, allowedSecurityLevel, population, gamebuild FROM realmlist WHERE flag <> 3 ORDER BY name", CONNECTION_SYNCH);
|
||||||
PrepareStatement(LOGIN_DEL_EXPIRED_IP_BANS, "DELETE FROM ip_banned WHERE unbandate<>bandate AND unbandate<=UNIX_TIMESTAMP()", CONNECTION_ASYNC);
|
PrepareStatement(LOGIN_DEL_EXPIRED_IP_BANS, "DELETE FROM ip_banned WHERE unbandate<>bandate AND unbandate<=UNIX_TIMESTAMP()", CONNECTION_ASYNC);
|
||||||
@@ -61,7 +64,20 @@ void LoginDatabaseConnection::DoPrepareStatements()
|
|||||||
PrepareStatement(LOGIN_INS_ACCOUNT_AUTO_BANNED, "INSERT INTO account_banned VALUES (?, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()+?, 'realmd', 'Failed login autoban', 1)", CONNECTION_ASYNC);
|
PrepareStatement(LOGIN_INS_ACCOUNT_AUTO_BANNED, "INSERT INTO account_banned VALUES (?, UNIX_TIMESTAMP(), UNIX_TIMESTAMP()+?, 'realmd', 'Failed login autoban', 1)", CONNECTION_ASYNC);
|
||||||
PrepareStatement(LOGIN_DEL_ACCOUNT_BANNED, "DELETE FROM account_banned WHERE id = ?", CONNECTION_ASYNC);
|
PrepareStatement(LOGIN_DEL_ACCOUNT_BANNED, "DELETE FROM account_banned WHERE id = ?", CONNECTION_ASYNC);
|
||||||
PrepareStatement(LOGIN_UPD_LOGON, "UPDATE account SET salt = ?, verifier = ? WHERE id = ?", CONNECTION_ASYNC);
|
PrepareStatement(LOGIN_UPD_LOGON, "UPDATE account SET salt = ?, verifier = ? WHERE id = ?", CONNECTION_ASYNC);
|
||||||
PrepareStatement(LOGIN_UPD_LOGONPROOF, "UPDATE account SET session_key = ?, last_ip = ?, last_login = NOW(), locale = ?, failed_logins = 0, os = ? WHERE username = ?", CONNECTION_ASYNC);
|
PrepareStatement(LOGIN_UPD_LOGONPROOF, "UPDATE account SET session_key = ?, session_auth = 'dev', last_ip = ?, last_login = NOW(), locale = ?, failed_logins = 0, os = ? WHERE username = ?", CONNECTION_ASYNC);
|
||||||
|
PrepareStatement(LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION,
|
||||||
|
"INSERT INTO launcher_ticket_consumption (generation_id, account_id, claim_nonce, consumed_at) "
|
||||||
|
"SELECT generation_id, account_id, ?, UTC_TIMESTAMP(6) FROM launcher_ticket "
|
||||||
|
"WHERE account_id = ? AND generation_id = ? AND expires_at > UTC_TIMESTAMP(6)", CONNECTION_ASYNC);
|
||||||
|
PrepareStatement(LOGIN_DEL_LAUNCHER_TICKET,
|
||||||
|
"DELETE FROM launcher_ticket WHERE account_id = ? AND generation_id = ?", CONNECTION_ASYNC);
|
||||||
|
PrepareStatement(LOGIN_UPD_LAUNCHER_LOGONPROOF,
|
||||||
|
"UPDATE account a JOIN launcher_ticket_consumption c ON c.account_id = a.id "
|
||||||
|
"SET a.session_key = ?, a.session_auth = 'launcher', a.last_ip = ?, a.last_login = NOW(), "
|
||||||
|
"a.locale = ?, a.failed_logins = 0, a.os = ? "
|
||||||
|
"WHERE a.id = ? AND c.generation_id = ? AND c.claim_nonce = ?", CONNECTION_ASYNC);
|
||||||
|
PrepareStatement(LOGIN_SEL_LAUNCHER_TICKET_CLAIM,
|
||||||
|
"SELECT 1 FROM launcher_ticket_consumption WHERE account_id = ? AND generation_id = ? AND claim_nonce = ?", CONNECTION_ASYNC);
|
||||||
PrepareStatement(LOGIN_UPD_FAILEDLOGINS, "UPDATE account SET failed_logins = failed_logins + 1 WHERE username = ?", CONNECTION_ASYNC);
|
PrepareStatement(LOGIN_UPD_FAILEDLOGINS, "UPDATE account SET failed_logins = failed_logins + 1 WHERE username = ?", CONNECTION_ASYNC);
|
||||||
PrepareStatement(LOGIN_SEL_FAILEDLOGINS, "SELECT id, failed_logins FROM account WHERE username = ?", CONNECTION_SYNCH);
|
PrepareStatement(LOGIN_SEL_FAILEDLOGINS, "SELECT id, failed_logins FROM account WHERE username = ?", CONNECTION_SYNCH);
|
||||||
PrepareStatement(LOGIN_SEL_ACCOUNT_ID_BY_NAME, "SELECT id FROM account WHERE username = ?", CONNECTION_SYNCH);
|
PrepareStatement(LOGIN_SEL_ACCOUNT_ID_BY_NAME, "SELECT id FROM account WHERE username = ?", CONNECTION_SYNCH);
|
||||||
|
|||||||
@@ -41,6 +41,10 @@ enum LoginDatabaseStatements : uint32
|
|||||||
LOGIN_DEL_ACCOUNT_BANNED,
|
LOGIN_DEL_ACCOUNT_BANNED,
|
||||||
LOGIN_UPD_LOGON,
|
LOGIN_UPD_LOGON,
|
||||||
LOGIN_UPD_LOGONPROOF,
|
LOGIN_UPD_LOGONPROOF,
|
||||||
|
LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION,
|
||||||
|
LOGIN_DEL_LAUNCHER_TICKET,
|
||||||
|
LOGIN_UPD_LAUNCHER_LOGONPROOF,
|
||||||
|
LOGIN_SEL_LAUNCHER_TICKET_CLAIM,
|
||||||
LOGIN_SEL_LOGONCHALLENGE,
|
LOGIN_SEL_LOGONCHALLENGE,
|
||||||
LOGIN_SEL_RECONNECTCHALLENGE,
|
LOGIN_SEL_RECONNECTCHALLENGE,
|
||||||
LOGIN_UPD_FAILEDLOGINS,
|
LOGIN_UPD_FAILEDLOGINS,
|
||||||
|
|||||||
@@ -30,13 +30,13 @@
|
|||||||
#include "WorldPacket.h"
|
#include "WorldPacket.h"
|
||||||
#include "WorldSession.h"
|
#include "WorldSession.h"
|
||||||
|
|
||||||
//void called when player click on auctioneer npc
|
// void called when player click on auctioneer npc
|
||||||
void WorldSession::HandleAuctionHelloOpcode(WorldPacket& recvData)
|
void WorldSession::HandleAuctionHelloOpcode(WorldPacket &recvData)
|
||||||
{
|
{
|
||||||
ObjectGuid guid; //NPC guid
|
ObjectGuid guid; // NPC guid
|
||||||
recvData >> guid;
|
recvData >> guid;
|
||||||
|
|
||||||
Creature* unit = GetPlayer()->GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_AUCTIONEER);
|
Creature *unit = GetPlayer()->GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_AUCTIONEER);
|
||||||
if (!unit)
|
if (!unit)
|
||||||
{
|
{
|
||||||
LOG_DEBUG("network", "WORLD: HandleAuctionHelloOpcode - Unit ({}) not found or you can't interact with him.", guid.ToString());
|
LOG_DEBUG("network", "WORLD: HandleAuctionHelloOpcode - Unit ({}) not found or you can't interact with him.", guid.ToString());
|
||||||
@@ -50,8 +50,8 @@ void WorldSession::HandleAuctionHelloOpcode(WorldPacket& recvData)
|
|||||||
SendAuctionHello(guid, unit);
|
SendAuctionHello(guid, unit);
|
||||||
}
|
}
|
||||||
|
|
||||||
//this void causes that auction window is opened
|
// this void causes that auction window is opened
|
||||||
void WorldSession::SendAuctionHello(ObjectGuid guid, Creature* unit)
|
void WorldSession::SendAuctionHello(ObjectGuid guid, Creature *unit)
|
||||||
{
|
{
|
||||||
if (GetPlayer()->GetLevel() < sWorld->getIntConfig(CONFIG_AUCTION_LEVEL_REQ))
|
if (GetPlayer()->GetLevel() < sWorld->getIntConfig(CONFIG_AUCTION_LEVEL_REQ))
|
||||||
{
|
{
|
||||||
@@ -62,18 +62,18 @@ void WorldSession::SendAuctionHello(ObjectGuid guid, Creature* unit)
|
|||||||
if (!sScriptMgr->CanSendAuctionHello(this, guid, unit))
|
if (!sScriptMgr->CanSendAuctionHello(this, guid, unit))
|
||||||
return;
|
return;
|
||||||
|
|
||||||
AuctionHouseEntry const* ahEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(unit->GetFaction());
|
AuctionHouseEntry const *ahEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(unit->GetFaction());
|
||||||
if (!ahEntry)
|
if (!ahEntry)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
WorldPacket data(MSG_AUCTION_HELLO, 12);
|
WorldPacket data(MSG_AUCTION_HELLO, 12);
|
||||||
data << guid;
|
data << guid;
|
||||||
data << uint32(ahEntry->houseId);
|
data << uint32(ahEntry->houseId);
|
||||||
data << uint8(1); // 3.3.3: 1 - AH enabled, 0 - AH disabled
|
data << uint8(1); // 3.3.3: 1 - AH enabled, 0 - AH disabled
|
||||||
SendPacket(&data);
|
SendPacket(&data);
|
||||||
}
|
}
|
||||||
|
|
||||||
//call this method when player bids, creates, or deletes auction
|
// call this method when player bids, creates, or deletes auction
|
||||||
void WorldSession::SendAuctionCommandResult(uint32 auctionId, uint32 Action, uint32 ErrorCode, uint32 bidError)
|
void WorldSession::SendAuctionCommandResult(uint32 auctionId, uint32 Action, uint32 ErrorCode, uint32 bidError)
|
||||||
{
|
{
|
||||||
WorldPacket data(SMSG_AUCTION_COMMAND_RESULT, 16);
|
WorldPacket data(SMSG_AUCTION_COMMAND_RESULT, 16);
|
||||||
@@ -81,11 +81,11 @@ void WorldSession::SendAuctionCommandResult(uint32 auctionId, uint32 Action, uin
|
|||||||
data << Action;
|
data << Action;
|
||||||
data << ErrorCode;
|
data << ErrorCode;
|
||||||
if (!ErrorCode && Action)
|
if (!ErrorCode && Action)
|
||||||
data << bidError; //when bid, then send 0, once...
|
data << bidError; // when bid, then send 0, once...
|
||||||
SendPacket(&data);
|
SendPacket(&data);
|
||||||
}
|
}
|
||||||
|
|
||||||
//this function sends notification, if bidder is online
|
// this function sends notification, if bidder is online
|
||||||
void WorldSession::SendAuctionBidderNotification(uint32 location, uint32 auctionId, ObjectGuid bidder, uint32 bidSum, uint32 diff, uint32 item_template)
|
void WorldSession::SendAuctionBidderNotification(uint32 location, uint32 auctionId, ObjectGuid bidder, uint32 bidSum, uint32 diff, uint32 item_template)
|
||||||
{
|
{
|
||||||
WorldPacket data(SMSG_AUCTION_BIDDER_NOTIFICATION, (8 * 4));
|
WorldPacket data(SMSG_AUCTION_BIDDER_NOTIFICATION, (8 * 4));
|
||||||
@@ -99,22 +99,22 @@ void WorldSession::SendAuctionBidderNotification(uint32 location, uint32 auction
|
|||||||
SendPacket(&data);
|
SendPacket(&data);
|
||||||
}
|
}
|
||||||
|
|
||||||
//this void causes on client to display: "Your auction sold"
|
// this void causes on client to display: "Your auction sold"
|
||||||
void WorldSession::SendAuctionOwnerNotification(AuctionEntry* auction)
|
void WorldSession::SendAuctionOwnerNotification(AuctionEntry *auction)
|
||||||
{
|
{
|
||||||
WorldPacket data(SMSG_AUCTION_OWNER_NOTIFICATION, (8 * 4));
|
WorldPacket data(SMSG_AUCTION_OWNER_NOTIFICATION, (8 * 4));
|
||||||
data << uint32(auction->Id);
|
data << uint32(auction->Id);
|
||||||
data << uint32(auction->bid);
|
data << uint32(auction->bid);
|
||||||
data << uint32(0); //unk
|
data << uint32(0); // unk
|
||||||
data << uint64(0); //unk (bidder guid?)
|
data << uint64(0); // unk (bidder guid?)
|
||||||
data << uint32(auction->item_template);
|
data << uint32(auction->item_template);
|
||||||
data << uint32(0); //unk
|
data << uint32(0); // unk
|
||||||
data << float(0); //unk (time?)
|
data << float(0); // unk (time?)
|
||||||
SendPacket(&data);
|
SendPacket(&data);
|
||||||
}
|
}
|
||||||
|
|
||||||
//this void creates new auction and adds auction to some auctionhouse
|
// this void creates new auction and adds auction to some auctionhouse
|
||||||
void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
void WorldSession::HandleAuctionSellItem(WorldPacket &recvData)
|
||||||
{
|
{
|
||||||
ObjectGuid auctioneer;
|
ObjectGuid auctioneer;
|
||||||
uint32 itemsCount, etime, bid, buyout;
|
uint32 itemsCount, etime, bid, buyout;
|
||||||
@@ -161,19 +161,19 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
if (bid > MAX_MONEY_AMOUNT || buyout > MAX_MONEY_AMOUNT)
|
if (bid > MAX_MONEY_AMOUNT || buyout > MAX_MONEY_AMOUNT)
|
||||||
{
|
{
|
||||||
LOG_DEBUG("network", "WORLD: HandleAuctionSellItem - Player {} ({}) attempted to sell item with higher price than max gold amount.",
|
LOG_DEBUG("network", "WORLD: HandleAuctionSellItem - Player {} ({}) attempted to sell item with higher price than max gold amount.",
|
||||||
_player->GetName(), _player->GetGUID().ToString());
|
_player->GetName(), _player->GetGUID().ToString());
|
||||||
SendAuctionCommandResult(0, AUCTION_SELL_ITEM, ERR_AUCTION_DATABASE_ERROR);
|
SendAuctionCommandResult(0, AUCTION_SELL_ITEM, ERR_AUCTION_DATABASE_ERROR);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Creature* creature = GetPlayer()->GetNPCIfCanInteractWith(auctioneer, UNIT_NPC_FLAG_AUCTIONEER);
|
Creature *creature = GetPlayer()->GetNPCIfCanInteractWith(auctioneer, UNIT_NPC_FLAG_AUCTIONEER);
|
||||||
if (!creature)
|
if (!creature)
|
||||||
{
|
{
|
||||||
LOG_DEBUG("network", "WORLD: HandleAuctionSellItem - Unit ({}) not found or you can't interact with him.", auctioneer.ToString());
|
LOG_DEBUG("network", "WORLD: HandleAuctionSellItem - Unit ({}) not found or you can't interact with him.", auctioneer.ToString());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AuctionHouseEntry const* auctionHouseEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(creature->GetFaction());
|
AuctionHouseEntry const *auctionHouseEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(creature->GetFaction());
|
||||||
if (!auctionHouseEntry)
|
if (!auctionHouseEntry)
|
||||||
{
|
{
|
||||||
LOG_DEBUG("network", "WORLD: HandleAuctionSellItem - Unit ({}) has wrong faction.", auctioneer.ToString());
|
LOG_DEBUG("network", "WORLD: HandleAuctionSellItem - Unit ({}) has wrong faction.", auctioneer.ToString());
|
||||||
@@ -184,25 +184,25 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
|
|
||||||
switch (etime)
|
switch (etime)
|
||||||
{
|
{
|
||||||
case 1*MIN_AUCTION_TIME:
|
case 1 * MIN_AUCTION_TIME:
|
||||||
case 2*MIN_AUCTION_TIME:
|
case 2 * MIN_AUCTION_TIME:
|
||||||
case 4*MIN_AUCTION_TIME:
|
case 4 * MIN_AUCTION_TIME:
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
||||||
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
||||||
|
|
||||||
Item* items[MAX_AUCTION_ITEMS];
|
Item *items[MAX_AUCTION_ITEMS];
|
||||||
|
|
||||||
uint32 finalCount = 0;
|
uint32 finalCount = 0;
|
||||||
uint32 itemEntry = 0;
|
uint32 itemEntry = 0;
|
||||||
|
|
||||||
for (uint32 i = 0; i < itemsCount; ++i)
|
for (uint32 i = 0; i < itemsCount; ++i)
|
||||||
{
|
{
|
||||||
Item* item = _player->GetItemByGuid(itemGUIDs[i]);
|
Item *item = _player->GetItemByGuid(itemGUIDs[i]);
|
||||||
|
|
||||||
if (!item)
|
if (!item)
|
||||||
{
|
{
|
||||||
@@ -214,8 +214,8 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
itemEntry = item->GetTemplate()->ItemId;
|
itemEntry = item->GetTemplate()->ItemId;
|
||||||
|
|
||||||
if (sAuctionMgr->GetAItem(item->GetGUID()) || !item->CanBeTraded() || item->IsNotEmptyBag() ||
|
if (sAuctionMgr->GetAItem(item->GetGUID()) || !item->CanBeTraded() || item->IsNotEmptyBag() ||
|
||||||
item->GetTemplate()->HasFlag(ITEM_FLAG_CONJURED) || item->GetUInt32Value(ITEM_FIELD_DURATION) ||
|
item->GetTemplate()->HasFlag(ITEM_FLAG_CONJURED) || item->GetUInt32Value(ITEM_FIELD_DURATION) ||
|
||||||
item->GetCount() < count[i] || itemEntry != item->GetTemplate()->ItemId)
|
item->GetCount() < count[i] || itemEntry != item->GetTemplate()->ItemId)
|
||||||
{
|
{
|
||||||
SendAuctionCommandResult(0, AUCTION_SELL_ITEM, ERR_AUCTION_DATABASE_ERROR);
|
SendAuctionCommandResult(0, AUCTION_SELL_ITEM, ERR_AUCTION_DATABASE_ERROR);
|
||||||
return;
|
return;
|
||||||
@@ -246,7 +246,7 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
|
|
||||||
for (uint32 i = 0; i < itemsCount; ++i)
|
for (uint32 i = 0; i < itemsCount; ++i)
|
||||||
{
|
{
|
||||||
Item* item = items[i];
|
Item *item = items[i];
|
||||||
|
|
||||||
if (item->GetMaxStackCount() < finalCount)
|
if (item->GetMaxStackCount() < finalCount)
|
||||||
{
|
{
|
||||||
@@ -257,10 +257,10 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
|
|
||||||
for (uint32 i = 0; i < itemsCount; ++i)
|
for (uint32 i = 0; i < itemsCount; ++i)
|
||||||
{
|
{
|
||||||
Item* item = items[i];
|
Item *item = items[i];
|
||||||
|
|
||||||
uint32 auctionTime = uint32(etime * sWorld->getRate(RATE_AUCTION_TIME));
|
uint32 auctionTime = uint32(etime * sWorld->getRate(RATE_AUCTION_TIME));
|
||||||
AuctionHouseObject* auctionHouse = sAuctionMgr->GetAuctionsMap(creature->GetFaction());
|
AuctionHouseObject *auctionHouse = sAuctionMgr->GetAuctionsMap(creature->GetFaction());
|
||||||
|
|
||||||
uint32 deposit = sAuctionMgr->GetAuctionDeposit(auctionHouseEntry, etime, item, finalCount);
|
uint32 deposit = sAuctionMgr->GetAuctionDeposit(auctionHouseEntry, etime, item, finalCount);
|
||||||
if (!_player->HasEnoughMoney(deposit))
|
if (!_player->HasEnoughMoney(deposit))
|
||||||
@@ -271,22 +271,14 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
|
|
||||||
_player->ModifyMoney(-int32(deposit));
|
_player->ModifyMoney(-int32(deposit));
|
||||||
|
|
||||||
AuctionEntry* AH = new AuctionEntry;
|
AuctionEntry *AH = new AuctionEntry;
|
||||||
AH->Id = sObjectMgr->GenerateAuctionID();
|
AH->Id = sObjectMgr->GenerateAuctionID();
|
||||||
|
|
||||||
if (sWorld->getBoolConfig(CONFIG_ALLOW_TWO_SIDE_INTERACTION_AUCTION))
|
if (sWorld->getBoolConfig(CONFIG_ALLOW_TWO_SIDE_INTERACTION_AUCTION))
|
||||||
AH->houseId = AuctionHouseId::Neutral;
|
AH->houseId = AuctionHouseId::Neutral;
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
CreatureData const* auctioneerData = sObjectMgr->GetCreatureData(creature->GetSpawnId());
|
CreatureTemplate const *auctioneerInfo = creature->GetCreatureTemplate();
|
||||||
if (!auctioneerData)
|
|
||||||
{
|
|
||||||
LOG_ERROR("network.opcode", "Data for auctioneer not found ({})", auctioneer.ToString());
|
|
||||||
delete AH;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
CreatureTemplate const* auctioneerInfo = sObjectMgr->GetCreatureTemplate(auctioneerData->id);
|
|
||||||
if (!auctioneerInfo)
|
if (!auctioneerInfo)
|
||||||
{
|
{
|
||||||
LOG_ERROR("network.opcode", "Non existing auctioneer ({})", auctioneer.ToString());
|
LOG_ERROR("network.opcode", "Non existing auctioneer ({})", auctioneer.ToString());
|
||||||
@@ -294,7 +286,7 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
AuctionHouseEntry const* AHEntry = sAuctionMgr->GetAuctionHouseEntryFromFactionTemplate(auctioneerInfo->faction);
|
AuctionHouseEntry const *AHEntry = sAuctionMgr->GetAuctionHouseEntryFromFactionTemplate(auctioneerInfo->faction);
|
||||||
AH->houseId = AuctionHouseId(AHEntry->houseId);
|
AH->houseId = AuctionHouseId(AHEntry->houseId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -314,7 +306,7 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
AH->auctionHouseEntry = auctionHouseEntry;
|
AH->auctionHouseEntry = auctionHouseEntry;
|
||||||
|
|
||||||
LOG_DEBUG("network.opcode", "CMSG_AUCTION_SELL_ITEM: Player {} ({}) is selling item {} entry {} ({}) with count {} with initial bid {} with buyout {} and with time {} (in sec) in auctionhouse {}",
|
LOG_DEBUG("network.opcode", "CMSG_AUCTION_SELL_ITEM: Player {} ({}) is selling item {} entry {} ({}) with count {} with initial bid {} with buyout {} and with time {} (in sec) in auctionhouse {}",
|
||||||
_player->GetName(), _player->GetGUID().ToString(), item->GetTemplate()->Name1, item->GetEntry(), item->GetGUID().ToString(), item->GetCount(), bid, buyout, auctionTime, AH->GetHouseId());
|
_player->GetName(), _player->GetGUID().ToString(), item->GetTemplate()->Name1, item->GetEntry(), item->GetGUID().ToString(), item->GetCount(), bid, buyout, auctionTime, AH->GetHouseId());
|
||||||
sAuctionMgr->AddAItem(item);
|
sAuctionMgr->AddAItem(item);
|
||||||
auctionHouse->AddAuction(AH);
|
auctionHouse->AddAuction(AH);
|
||||||
|
|
||||||
@@ -332,21 +324,21 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
if (HasPermission(rbac::RBAC_PERM_LOG_GM_TRADE))
|
if (HasPermission(rbac::RBAC_PERM_LOG_GM_TRADE))
|
||||||
{
|
{
|
||||||
LOG_GM(GetAccountId(), "GM {} (Account: {}) created auction: {} (Item: {} Count: {}) Bid: {} Buyout: {}",
|
LOG_GM(GetAccountId(), "GM {} (Account: {}) created auction: {} (Item: {} Count: {}) Bid: {} Buyout: {}",
|
||||||
_player->GetName(), GetAccountId(), AH->Id,
|
_player->GetName(), GetAccountId(), AH->Id,
|
||||||
item->GetTemplate()->Name1, item->GetCount(), bid, buyout);
|
item->GetTemplate()->Name1, item->GetCount(), bid, buyout);
|
||||||
}
|
}
|
||||||
|
|
||||||
GetPlayer()->UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_CREATE_AUCTION, 1);
|
GetPlayer()->UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_CREATE_AUCTION, 1);
|
||||||
|
|
||||||
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) created auction #{}: Item '{}' (Entry: {}) x{}, StartBid: {} copper, Buyout: {} copper, Deposit: {} copper",
|
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) created auction #{}: Item '{}' (Entry: {}) x{}, StartBid: {} copper, Buyout: {} copper, Deposit: {} copper",
|
||||||
GetAccountId(), GetRemoteAddress(), _player->GetName(), _player->GetGUID().GetCounter(), AH->Id,
|
GetAccountId(), GetRemoteAddress(), _player->GetName(), _player->GetGUID().GetCounter(), AH->Id,
|
||||||
item->GetTemplate()->Name1, item->GetEntry(), item->GetCount(), bid, buyout, deposit);
|
item->GetTemplate()->Name1, item->GetEntry(), item->GetCount(), bid, buyout, deposit);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
else // Required stack size of auction does not match to current item stack size, clone item and set correct stack size
|
else // Required stack size of auction does not match to current item stack size, clone item and set correct stack size
|
||||||
{
|
{
|
||||||
Item* newItem = item->CloneItem(finalCount, _player);
|
Item *newItem = item->CloneItem(finalCount, _player);
|
||||||
if (!newItem)
|
if (!newItem)
|
||||||
{
|
{
|
||||||
LOG_ERROR("network.opcode", "CMSG_AUCTION_SELL_ITEM: Could not create clone of item {}", item->GetEntry());
|
LOG_ERROR("network.opcode", "CMSG_AUCTION_SELL_ITEM: Could not create clone of item {}", item->GetEntry());
|
||||||
@@ -367,13 +359,13 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
AH->auctionHouseEntry = auctionHouseEntry;
|
AH->auctionHouseEntry = auctionHouseEntry;
|
||||||
|
|
||||||
LOG_DEBUG("network.opcode", "CMSG_AUCTION_SELL_ITEM: Player {} ({}) is selling item {} entry {} ({}) with count {} with initial bid {} with buyout {} and with time {} (in sec) in auctionhouse {}",
|
LOG_DEBUG("network.opcode", "CMSG_AUCTION_SELL_ITEM: Player {} ({}) is selling item {} entry {} ({}) with count {} with initial bid {} with buyout {} and with time {} (in sec) in auctionhouse {}",
|
||||||
_player->GetName(), _player->GetGUID().ToString(), newItem->GetTemplate()->Name1, newItem->GetEntry(), newItem->GetGUID().ToString(), newItem->GetCount(), bid, buyout, auctionTime, AH->GetHouseId());
|
_player->GetName(), _player->GetGUID().ToString(), newItem->GetTemplate()->Name1, newItem->GetEntry(), newItem->GetGUID().ToString(), newItem->GetCount(), bid, buyout, auctionTime, AH->GetHouseId());
|
||||||
sAuctionMgr->AddAItem(newItem);
|
sAuctionMgr->AddAItem(newItem);
|
||||||
auctionHouse->AddAuction(AH);
|
auctionHouse->AddAuction(AH);
|
||||||
|
|
||||||
for (uint32 j = 0; j < itemsCount; ++j)
|
for (uint32 j = 0; j < itemsCount; ++j)
|
||||||
{
|
{
|
||||||
Item* item2 = items[j];
|
Item *item2 = items[j];
|
||||||
|
|
||||||
// Item stack count equals required count, ready to delete item - cloned item will be used for auction
|
// Item stack count equals required count, ready to delete item - cloned item will be used for auction
|
||||||
if (item2->GetCount() == count[j])
|
if (item2->GetCount() == count[j])
|
||||||
@@ -406,23 +398,23 @@ void WorldSession::HandleAuctionSellItem(WorldPacket& recvData)
|
|||||||
if (HasPermission(rbac::RBAC_PERM_LOG_GM_TRADE))
|
if (HasPermission(rbac::RBAC_PERM_LOG_GM_TRADE))
|
||||||
{
|
{
|
||||||
LOG_GM(GetAccountId(), "GM {} (Account: {}) created auction: {} (Item: {} Count: {}) Bid: {} Buyout: {}",
|
LOG_GM(GetAccountId(), "GM {} (Account: {}) created auction: {} (Item: {} Count: {}) Bid: {} Buyout: {}",
|
||||||
_player->GetName(), GetAccountId(), AH->Id,
|
_player->GetName(), GetAccountId(), AH->Id,
|
||||||
newItem->GetTemplate()->Name1, newItem->GetCount(), bid, buyout);
|
newItem->GetTemplate()->Name1, newItem->GetCount(), bid, buyout);
|
||||||
}
|
}
|
||||||
|
|
||||||
GetPlayer()->UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_CREATE_AUCTION, 1);
|
GetPlayer()->UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_CREATE_AUCTION, 1);
|
||||||
|
|
||||||
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) created auction #{}: Item '{}' (Entry: {}) x{}, StartBid: {} copper, Buyout: {} copper, Deposit: {} copper",
|
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) created auction #{}: Item '{}' (Entry: {}) x{}, StartBid: {} copper, Buyout: {} copper, Deposit: {} copper",
|
||||||
GetAccountId(), GetRemoteAddress(), _player->GetName(), _player->GetGUID().GetCounter(), AH->Id,
|
GetAccountId(), GetRemoteAddress(), _player->GetName(), _player->GetGUID().GetCounter(), AH->Id,
|
||||||
newItem->GetTemplate()->Name1, newItem->GetEntry(), newItem->GetCount(), bid, buyout, deposit);
|
newItem->GetTemplate()->Name1, newItem->GetEntry(), newItem->GetCount(), bid, buyout, deposit);
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
//this function is called when client bids or buys out auction
|
// this function is called when client bids or buys out auction
|
||||||
void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
void WorldSession::HandleAuctionPlaceBid(WorldPacket &recvData)
|
||||||
{
|
{
|
||||||
ObjectGuid auctioneer;
|
ObjectGuid auctioneer;
|
||||||
uint32 auctionId;
|
uint32 auctionId;
|
||||||
@@ -431,7 +423,7 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
|||||||
recvData >> auctionId >> price;
|
recvData >> auctionId >> price;
|
||||||
|
|
||||||
if (!auctionId || !price)
|
if (!auctionId || !price)
|
||||||
return; //check for cheaters
|
return; // check for cheaters
|
||||||
|
|
||||||
if (sWorld->getBoolConfig(CONFIG_TRIAL_RESTRICTION_AUCTION) && IsTrialAccount())
|
if (sWorld->getBoolConfig(CONFIG_TRIAL_RESTRICTION_AUCTION) && IsTrialAccount())
|
||||||
{
|
{
|
||||||
@@ -439,7 +431,7 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Creature* creature = GetPlayer()->GetNPCIfCanInteractWith(auctioneer, UNIT_NPC_FLAG_AUCTIONEER);
|
Creature *creature = GetPlayer()->GetNPCIfCanInteractWith(auctioneer, UNIT_NPC_FLAG_AUCTIONEER);
|
||||||
if (!creature)
|
if (!creature)
|
||||||
{
|
{
|
||||||
LOG_DEBUG("network", "WORLD: HandleAuctionPlaceBid - Unit ({}) not found or you can't interact with him.", auctioneer.ToString());
|
LOG_DEBUG("network", "WORLD: HandleAuctionPlaceBid - Unit ({}) not found or you can't interact with him.", auctioneer.ToString());
|
||||||
@@ -450,10 +442,10 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
|||||||
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
||||||
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
||||||
|
|
||||||
AuctionHouseObject* auctionHouse = sAuctionMgr->GetAuctionsMap(creature->GetFaction());
|
AuctionHouseObject *auctionHouse = sAuctionMgr->GetAuctionsMap(creature->GetFaction());
|
||||||
|
|
||||||
AuctionEntry* auction = auctionHouse->GetAuction(auctionId);
|
AuctionEntry *auction = auctionHouse->GetAuction(auctionId);
|
||||||
Player* player = GetPlayer();
|
Player *player = GetPlayer();
|
||||||
|
|
||||||
if (!sScriptMgr->OnPlayerCanPlaceAuctionBid(player, auction))
|
if (!sScriptMgr->OnPlayerCanPlaceAuctionBid(player, auction))
|
||||||
{
|
{
|
||||||
@@ -470,16 +462,16 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
|||||||
|
|
||||||
if (auction->owner == player->GetGUID())
|
if (auction->owner == player->GetGUID())
|
||||||
{
|
{
|
||||||
//you cannot bid your own auction:
|
// you cannot bid your own auction:
|
||||||
SendAuctionCommandResult(0, AUCTION_PLACE_BID, ERR_AUCTION_BID_OWN);
|
SendAuctionCommandResult(0, AUCTION_PLACE_BID, ERR_AUCTION_BID_OWN);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// impossible have online own another character (use this for speedup check in case online owner)
|
// impossible have online own another character (use this for speedup check in case online owner)
|
||||||
Player* auction_owner = ObjectAccessor::FindConnectedPlayer(auction->owner);
|
Player *auction_owner = ObjectAccessor::FindConnectedPlayer(auction->owner);
|
||||||
if (!auction_owner && sCharacterCache->GetCharacterAccountIdByGuid(auction->owner) == GetAccountId())
|
if (!auction_owner && sCharacterCache->GetCharacterAccountIdByGuid(auction->owner) == GetAccountId())
|
||||||
{
|
{
|
||||||
//you cannot bid your another character auction:
|
// you cannot bid your another character auction:
|
||||||
SendAuctionCommandResult(0, AUCTION_PLACE_BID, ERR_AUCTION_BID_OWN);
|
SendAuctionCommandResult(0, AUCTION_PLACE_BID, ERR_AUCTION_BID_OWN);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -490,16 +482,16 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
|||||||
|
|
||||||
// price too low for next bid if not buyout
|
// price too low for next bid if not buyout
|
||||||
if ((price < auction->buyout || auction->buyout == 0) &&
|
if ((price < auction->buyout || auction->buyout == 0) &&
|
||||||
price < auction->bid + AuctionEntry::CalculateAuctionOutBid(auction->bid))
|
price < auction->bid + AuctionEntry::CalculateAuctionOutBid(auction->bid))
|
||||||
{
|
{
|
||||||
//auction has already higher bid, client tests it!
|
// auction has already higher bid, client tests it!
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!player->HasEnoughMoney(price))
|
if (!player->HasEnoughMoney(price))
|
||||||
{
|
{
|
||||||
//you don't have enought money!, client tests!
|
// you don't have enought money!, client tests!
|
||||||
//SendAuctionCommandResult(auction->auctionId, AUCTION_PLACE_BID, ???);
|
// SendAuctionCommandResult(auction->auctionId, AUCTION_PLACE_BID, ???);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -528,7 +520,7 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
|||||||
|
|
||||||
GetPlayer()->UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_HIGHEST_AUCTION_BID, price);
|
GetPlayer()->UpdateAchievementCriteria(ACHIEVEMENT_CRITERIA_TYPE_HIGHEST_AUCTION_BID, price);
|
||||||
|
|
||||||
CharacterDatabasePreparedStatement* stmt = CharacterDatabase.GetPreparedStatement(CHAR_UPD_AUCTION_BID);
|
CharacterDatabasePreparedStatement *stmt = CharacterDatabase.GetPreparedStatement(CHAR_UPD_AUCTION_BID);
|
||||||
stmt->SetData(0, auction->bidder.GetCounter());
|
stmt->SetData(0, auction->bidder.GetCounter());
|
||||||
stmt->SetData(1, auction->bid);
|
stmt->SetData(1, auction->bid);
|
||||||
stmt->SetData(2, auction->Id);
|
stmt->SetData(2, auction->Id);
|
||||||
@@ -539,23 +531,23 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
|||||||
if (HasPermission(rbac::RBAC_PERM_LOG_GM_TRADE))
|
if (HasPermission(rbac::RBAC_PERM_LOG_GM_TRADE))
|
||||||
{
|
{
|
||||||
LOG_GM(GetAccountId(), "GM {} (Account: {}) bid on auction: {} (Item: {} Count: {}) Bid: {}",
|
LOG_GM(GetAccountId(), "GM {} (Account: {}) bid on auction: {} (Item: {} Count: {}) Bid: {}",
|
||||||
player->GetName(), GetAccountId(), auction->Id,
|
player->GetName(), GetAccountId(), auction->Id,
|
||||||
auction->item_template, auction->itemCount, price);
|
auction->item_template, auction->itemCount, price);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) placed bid on auction #{}: Item (Entry: {}) x{}, Bid: {} copper, Owner: {} (GUID: {})",
|
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) placed bid on auction #{}: Item (Entry: {}) x{}, Bid: {} copper, Owner: {} (GUID: {})",
|
||||||
GetAccountId(), GetRemoteAddress(), player->GetName(), player->GetGUID().GetCounter(),
|
GetAccountId(), GetRemoteAddress(), player->GetName(), player->GetGUID().GetCounter(),
|
||||||
auction->Id, auction->item_template, auction->itemCount, price, auction->owner.GetCounter(), auction->owner.GetCounter());
|
auction->Id, auction->item_template, auction->itemCount, price, auction->owner.GetCounter(), auction->owner.GetCounter());
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
//buyout:
|
// buyout:
|
||||||
if (player->GetGUID() == auction->bidder)
|
if (player->GetGUID() == auction->bidder)
|
||||||
player->ModifyMoney(-int32(auction->buyout - auction->bid));
|
player->ModifyMoney(-int32(auction->buyout - auction->bid));
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
player->ModifyMoney(-int32(auction->buyout));
|
player->ModifyMoney(-int32(auction->buyout));
|
||||||
if (auction->bidder) //buyout for bidded auction ..
|
if (auction->bidder) // buyout for bidded auction ..
|
||||||
sAuctionMgr->SendAuctionOutbiddedMail(auction, auction->buyout, GetPlayer(), trans);
|
sAuctionMgr->SendAuctionOutbiddedMail(auction, auction->buyout, GetPlayer(), trans);
|
||||||
}
|
}
|
||||||
auction->bidder = player->GetGUID();
|
auction->bidder = player->GetGUID();
|
||||||
@@ -573,13 +565,13 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
|||||||
if (HasPermission(rbac::RBAC_PERM_LOG_GM_TRADE))
|
if (HasPermission(rbac::RBAC_PERM_LOG_GM_TRADE))
|
||||||
{
|
{
|
||||||
LOG_GM(GetAccountId(), "GM {} (Account: {}) bought out auction: {} (Item: {} Count: {}) Buyout: {}",
|
LOG_GM(GetAccountId(), "GM {} (Account: {}) bought out auction: {} (Item: {} Count: {}) Buyout: {}",
|
||||||
player->GetName(), GetAccountId(), auction->Id,
|
player->GetName(), GetAccountId(), auction->Id,
|
||||||
auction->item_template, auction->itemCount, auction->buyout);
|
auction->item_template, auction->itemCount, auction->buyout);
|
||||||
}
|
}
|
||||||
|
|
||||||
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) bought out auction #{}: Item (Entry: {}) x{}, Buyout: {} copper, Owner: {} (GUID: {})",
|
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) bought out auction #{}: Item (Entry: {}) x{}, Buyout: {} copper, Owner: {} (GUID: {})",
|
||||||
GetAccountId(), GetRemoteAddress(), player->GetName(), player->GetGUID().GetCounter(),
|
GetAccountId(), GetRemoteAddress(), player->GetName(), player->GetGUID().GetCounter(),
|
||||||
auction->Id, auction->item_template, auction->itemCount, auction->buyout, auction->owner.GetCounter(), auction->owner.GetCounter());
|
auction->Id, auction->item_template, auction->itemCount, auction->buyout, auction->owner.GetCounter(), auction->owner.GetCounter());
|
||||||
|
|
||||||
auction->DeleteFromDB(trans);
|
auction->DeleteFromDB(trans);
|
||||||
|
|
||||||
@@ -590,8 +582,8 @@ void WorldSession::HandleAuctionPlaceBid(WorldPacket& recvData)
|
|||||||
CharacterDatabase.CommitTransaction(trans);
|
CharacterDatabase.CommitTransaction(trans);
|
||||||
}
|
}
|
||||||
|
|
||||||
//this void is called when auction_owner cancels his auction
|
// this void is called when auction_owner cancels his auction
|
||||||
void WorldSession::HandleAuctionRemoveItem(WorldPacket& recvData)
|
void WorldSession::HandleAuctionRemoveItem(WorldPacket &recvData)
|
||||||
{
|
{
|
||||||
ObjectGuid auctioneer;
|
ObjectGuid auctioneer;
|
||||||
uint32 auctionId;
|
uint32 auctionId;
|
||||||
@@ -604,7 +596,7 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recvData)
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
Creature* creature = GetPlayer()->GetNPCIfCanInteractWith(auctioneer, UNIT_NPC_FLAG_AUCTIONEER);
|
Creature *creature = GetPlayer()->GetNPCIfCanInteractWith(auctioneer, UNIT_NPC_FLAG_AUCTIONEER);
|
||||||
if (!creature)
|
if (!creature)
|
||||||
{
|
{
|
||||||
LOG_DEBUG("network", "WORLD: HandleAuctionRemoveItem - Unit ({}) not found or you can't interact with him.", auctioneer.ToString());
|
LOG_DEBUG("network", "WORLD: HandleAuctionRemoveItem - Unit ({}) not found or you can't interact with him.", auctioneer.ToString());
|
||||||
@@ -615,47 +607,47 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recvData)
|
|||||||
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
||||||
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
||||||
|
|
||||||
AuctionHouseObject* auctionHouse = sAuctionMgr->GetAuctionsMap(creature->GetFaction());
|
AuctionHouseObject *auctionHouse = sAuctionMgr->GetAuctionsMap(creature->GetFaction());
|
||||||
|
|
||||||
AuctionEntry* auction = auctionHouse->GetAuction(auctionId);
|
AuctionEntry *auction = auctionHouse->GetAuction(auctionId);
|
||||||
Player* player = GetPlayer();
|
Player *player = GetPlayer();
|
||||||
|
|
||||||
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
CharacterDatabaseTransaction trans = CharacterDatabase.BeginTransaction();
|
||||||
if (auction && auction->owner == player->GetGUID())
|
if (auction && auction->owner == player->GetGUID())
|
||||||
{
|
{
|
||||||
Item* pItem = sAuctionMgr->GetAItem(auction->item_guid);
|
Item *pItem = sAuctionMgr->GetAItem(auction->item_guid);
|
||||||
if (!pItem)
|
if (!pItem)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
if (auction->bidder) // If we have a bidder, we have to send him the money he paid
|
if (auction->bidder) // If we have a bidder, we have to send him the money he paid
|
||||||
{
|
{
|
||||||
uint32 auctionCut = auction->GetAuctionCut();
|
uint32 auctionCut = auction->GetAuctionCut();
|
||||||
if (!player->HasEnoughMoney(auctionCut)) //player doesn't have enough money, maybe message needed
|
if (!player->HasEnoughMoney(auctionCut)) // player doesn't have enough money, maybe message needed
|
||||||
return;
|
return;
|
||||||
//some auctionBidderNotification would be needed, but don't know that parts..
|
// some auctionBidderNotification would be needed, but don't know that parts..
|
||||||
sAuctionMgr->SendAuctionCancelledToBidderMail(auction, trans);
|
sAuctionMgr->SendAuctionCancelledToBidderMail(auction, trans);
|
||||||
player->ModifyMoney(-int32(auctionCut));
|
player->ModifyMoney(-int32(auctionCut));
|
||||||
}
|
}
|
||||||
|
|
||||||
// item will deleted or added to received mail list
|
// item will deleted or added to received mail list
|
||||||
MailDraft(auction->BuildAuctionMailSubject(AUCTION_CANCELED), AuctionEntry::BuildAuctionMailBody(ObjectGuid::Empty, 0, auction->buyout, auction->deposit))
|
MailDraft(auction->BuildAuctionMailSubject(AUCTION_CANCELED), AuctionEntry::BuildAuctionMailBody(ObjectGuid::Empty, 0, auction->buyout, auction->deposit))
|
||||||
.AddItem(pItem)
|
.AddItem(pItem)
|
||||||
.SendMailTo(trans, player, auction, MAIL_CHECK_MASK_COPIED);
|
.SendMailTo(trans, player, auction, MAIL_CHECK_MASK_COPIED);
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
SendAuctionCommandResult(0, AUCTION_CANCEL, ERR_AUCTION_DATABASE_ERROR);
|
SendAuctionCommandResult(0, AUCTION_CANCEL, ERR_AUCTION_DATABASE_ERROR);
|
||||||
//this code isn't possible ... maybe there should be assert
|
// this code isn't possible ... maybe there should be assert
|
||||||
LOG_ERROR("network.opcode", "CHEATER : {}, he tried to cancel auction (id: {}) of another player, or auction is nullptr", player->GetGUID().ToString(), auctionId);
|
LOG_ERROR("network.opcode", "CHEATER : {}, he tried to cancel auction (id: {}) of another player, or auction is nullptr", player->GetGUID().ToString(), auctionId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
//inform player, that auction is removed
|
// inform player, that auction is removed
|
||||||
SendAuctionCommandResult(auction->Id, AUCTION_CANCEL, ERR_AUCTION_OK);
|
SendAuctionCommandResult(auction->Id, AUCTION_CANCEL, ERR_AUCTION_OK);
|
||||||
|
|
||||||
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) cancelled auction #{}: Item (Entry: {}) x{}, Buyout: {} copper",
|
LOG_INFO("entities.player.auctionhouse", "AuctionHouse: Account: {} (IP: {}), Player [{}] (GUID: {}) cancelled auction #{}: Item (Entry: {}) x{}, Buyout: {} copper",
|
||||||
GetAccountId(), GetRemoteAddress(), player->GetName(), player->GetGUID().GetCounter(),
|
GetAccountId(), GetRemoteAddress(), player->GetName(), player->GetGUID().GetCounter(),
|
||||||
auction->Id, auction->item_template, auction->itemCount, auction->buyout);
|
auction->Id, auction->item_template, auction->itemCount, auction->buyout);
|
||||||
|
|
||||||
// Now remove the auction
|
// Now remove the auction
|
||||||
|
|
||||||
@@ -667,15 +659,15 @@ void WorldSession::HandleAuctionRemoveItem(WorldPacket& recvData)
|
|||||||
auctionHouse->RemoveAuction(auction);
|
auctionHouse->RemoveAuction(auction);
|
||||||
}
|
}
|
||||||
|
|
||||||
//called when player lists his bids
|
// called when player lists his bids
|
||||||
void WorldSession::HandleAuctionListBidderItems(WorldPacket& recvData)
|
void WorldSession::HandleAuctionListBidderItems(WorldPacket &recvData)
|
||||||
{
|
{
|
||||||
ObjectGuid guid; //NPC guid
|
ObjectGuid guid; // NPC guid
|
||||||
uint32 listfrom; //page of auctions
|
uint32 listfrom; // page of auctions
|
||||||
uint32 outbiddedCount; //count of outbidded auctions
|
uint32 outbiddedCount; // count of outbidded auctions
|
||||||
|
|
||||||
recvData >> guid;
|
recvData >> guid;
|
||||||
recvData >> listfrom; // not used in fact (this list not have page control in client)
|
recvData >> listfrom; // not used in fact (this list not have page control in client)
|
||||||
recvData >> outbiddedCount;
|
recvData >> outbiddedCount;
|
||||||
if (recvData.size() != (16 + outbiddedCount * 4))
|
if (recvData.size() != (16 + outbiddedCount * 4))
|
||||||
{
|
{
|
||||||
@@ -683,7 +675,7 @@ void WorldSession::HandleAuctionListBidderItems(WorldPacket& recvData)
|
|||||||
outbiddedCount = 0;
|
outbiddedCount = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
Creature* creature = GetPlayer()->GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_AUCTIONEER);
|
Creature *creature = GetPlayer()->GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_AUCTIONEER);
|
||||||
if (!creature)
|
if (!creature)
|
||||||
{
|
{
|
||||||
LOG_DEBUG("network", "WORLD: HandleAuctionListBidderItems - Unit ({}) not found or you can't interact with him.", guid.ToString());
|
LOG_DEBUG("network", "WORLD: HandleAuctionListBidderItems - Unit ({}) not found or you can't interact with him.", guid.ToString());
|
||||||
@@ -699,7 +691,7 @@ void WorldSession::HandleAuctionListBidderItems(WorldPacket& recvData)
|
|||||||
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
||||||
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
||||||
|
|
||||||
AuctionHouseEntry const* ahEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(creature->GetFaction());
|
AuctionHouseEntry const *ahEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(creature->GetFaction());
|
||||||
if (!ahEntry)
|
if (!ahEntry)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -719,16 +711,16 @@ void WorldSession::HandleAuctionListBidderItems(WorldPacket& recvData)
|
|||||||
sAuctionMgr->GetAuctionHouseSearcher()->QueueSearchRequest(new AuctionSearchBidderListRequest(auctionHouseFaction, std::move(auctionIds), GetPlayer()->GetGUID()));
|
sAuctionMgr->GetAuctionHouseSearcher()->QueueSearchRequest(new AuctionSearchBidderListRequest(auctionHouseFaction, std::move(auctionIds), GetPlayer()->GetGUID()));
|
||||||
}
|
}
|
||||||
|
|
||||||
//this void sends player info about his auctions
|
// this void sends player info about his auctions
|
||||||
void WorldSession::HandleAuctionListOwnerItems(WorldPacket& recvData)
|
void WorldSession::HandleAuctionListOwnerItems(WorldPacket &recvData)
|
||||||
{
|
{
|
||||||
ObjectGuid guid;
|
ObjectGuid guid;
|
||||||
uint32 listfrom;
|
uint32 listfrom;
|
||||||
|
|
||||||
recvData >> guid;
|
recvData >> guid;
|
||||||
recvData >> listfrom; // not used in fact (this list does not have page control in client)
|
recvData >> listfrom; // not used in fact (this list does not have page control in client)
|
||||||
|
|
||||||
Creature* creature = GetPlayer()->GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_AUCTIONEER);
|
Creature *creature = GetPlayer()->GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_AUCTIONEER);
|
||||||
if (!creature)
|
if (!creature)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -736,7 +728,7 @@ void WorldSession::HandleAuctionListOwnerItems(WorldPacket& recvData)
|
|||||||
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
if (GetPlayer()->HasUnitState(UNIT_STATE_DIED))
|
||||||
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
GetPlayer()->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
||||||
|
|
||||||
AuctionHouseEntry const* ahEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(creature->GetFaction());
|
AuctionHouseEntry const *ahEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(creature->GetFaction());
|
||||||
if (!ahEntry)
|
if (!ahEntry)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -745,8 +737,8 @@ void WorldSession::HandleAuctionListOwnerItems(WorldPacket& recvData)
|
|||||||
sAuctionMgr->GetAuctionHouseSearcher()->QueueSearchRequest(new AuctionSearchOwnerListRequest(auctionHouseFaction, GetPlayer()->GetGUID()));
|
sAuctionMgr->GetAuctionHouseSearcher()->QueueSearchRequest(new AuctionSearchOwnerListRequest(auctionHouseFaction, GetPlayer()->GetGUID()));
|
||||||
}
|
}
|
||||||
|
|
||||||
//this void is called when player clicks on search button
|
// this void is called when player clicks on search button
|
||||||
void WorldSession::HandleAuctionListItems(WorldPacket& recvData)
|
void WorldSession::HandleAuctionListItems(WorldPacket &recvData)
|
||||||
{
|
{
|
||||||
std::string searchedname;
|
std::string searchedname;
|
||||||
uint8 levelmin, levelmax, usable;
|
uint8 levelmin, levelmax, usable;
|
||||||
@@ -754,7 +746,7 @@ void WorldSession::HandleAuctionListItems(WorldPacket& recvData)
|
|||||||
ObjectGuid guid;
|
ObjectGuid guid;
|
||||||
|
|
||||||
recvData >> guid;
|
recvData >> guid;
|
||||||
recvData >> listfrom; // start, used for page control listing by 50 elements
|
recvData >> listfrom; // start, used for page control listing by 50 elements
|
||||||
recvData >> searchedname;
|
recvData >> searchedname;
|
||||||
|
|
||||||
recvData >> levelmin >> levelmax;
|
recvData >> levelmin >> levelmax;
|
||||||
@@ -791,7 +783,7 @@ void WorldSession::HandleAuctionListItems(WorldPacket& recvData)
|
|||||||
|
|
||||||
wstrToLower(wsearchedname);
|
wstrToLower(wsearchedname);
|
||||||
|
|
||||||
Creature* creature = GetPlayer()->GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_AUCTIONEER);
|
Creature *creature = GetPlayer()->GetNPCIfCanInteractWith(guid, UNIT_NPC_FLAG_AUCTIONEER);
|
||||||
if (!creature)
|
if (!creature)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -799,7 +791,7 @@ void WorldSession::HandleAuctionListItems(WorldPacket& recvData)
|
|||||||
if (_player->HasUnitState(UNIT_STATE_DIED))
|
if (_player->HasUnitState(UNIT_STATE_DIED))
|
||||||
_player->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
_player->RemoveAurasByType(SPELL_AURA_FEIGN_DEATH);
|
||||||
|
|
||||||
AuctionHouseEntry const* ahEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(creature->GetFaction());
|
AuctionHouseEntry const *ahEntry = AuctionHouseMgr::GetAuctionHouseEntryFromFactionTemplate(creature->GetFaction());
|
||||||
if (!ahEntry)
|
if (!ahEntry)
|
||||||
return;
|
return;
|
||||||
|
|
||||||
@@ -830,12 +822,12 @@ void WorldSession::HandleAuctionListItems(WorldPacket& recvData)
|
|||||||
usablePlayerInfo.raceMask = GetPlayer()->getRaceMask();
|
usablePlayerInfo.raceMask = GetPlayer()->getRaceMask();
|
||||||
usablePlayerInfo.level = GetPlayer()->GetLevel();
|
usablePlayerInfo.level = GetPlayer()->GetLevel();
|
||||||
|
|
||||||
SkillStatusMap const& skillMap = GetPlayer()->GetSkillStatusMap();
|
SkillStatusMap const &skillMap = GetPlayer()->GetSkillStatusMap();
|
||||||
for (auto const& pair : skillMap)
|
for (auto const &pair : skillMap)
|
||||||
usablePlayerInfo.skills.insert(std::make_pair(pair.first, GetPlayer()->GetSkillValue(pair.first)));
|
usablePlayerInfo.skills.insert(std::make_pair(pair.first, GetPlayer()->GetSkillValue(pair.first)));
|
||||||
|
|
||||||
PlayerSpellMap const& spellMap = GetPlayer()->GetSpellMap();
|
PlayerSpellMap const &spellMap = GetPlayer()->GetSpellMap();
|
||||||
for (auto const& pair : spellMap)
|
for (auto const &pair : spellMap)
|
||||||
{
|
{
|
||||||
if (pair.second->State != PLAYERSPELL_REMOVED && pair.second->IsInSpec(GetPlayer()->GetActiveSpec()))
|
if (pair.second->State != PLAYERSPELL_REMOVED && pair.second->IsInSpec(GetPlayer()->GetActiveSpec()))
|
||||||
usablePlayerInfo.spells.insert(pair.first);
|
usablePlayerInfo.spells.insert(pair.first);
|
||||||
@@ -846,14 +838,14 @@ void WorldSession::HandleAuctionListItems(WorldPacket& recvData)
|
|||||||
sAuctionMgr->GetAuctionHouseSearcher()->QueueSearchRequest(new AuctionSearchListRequest(auctionHouseFaction, std::move(ahSearchInfo), std::move(ahPlayerInfo)));
|
sAuctionMgr->GetAuctionHouseSearcher()->QueueSearchRequest(new AuctionSearchListRequest(auctionHouseFaction, std::move(ahSearchInfo), std::move(ahPlayerInfo)));
|
||||||
}
|
}
|
||||||
|
|
||||||
void WorldSession::HandleAuctionListPendingSales(WorldPacket& recvData)
|
void WorldSession::HandleAuctionListPendingSales(WorldPacket &recvData)
|
||||||
{
|
{
|
||||||
recvData.read_skip<uint64>();
|
recvData.read_skip<uint64>();
|
||||||
|
|
||||||
uint32 count = 0;
|
uint32 count = 0;
|
||||||
|
|
||||||
WorldPacket data(SMSG_AUCTION_LIST_PENDING_SALES, 4);
|
WorldPacket data(SMSG_AUCTION_LIST_PENDING_SALES, 4);
|
||||||
data << uint32(count); // count
|
data << uint32(count); // count
|
||||||
/*for (uint32 i = 0; i < count; ++i)
|
/*for (uint32 i = 0; i < count; ++i)
|
||||||
{
|
{
|
||||||
data << ""; // string
|
data << ""; // string
|
||||||
|
|||||||
Reference in New Issue
Block a user