9 Commits

Author SHA1 Message Date
sindoring 3405057e5b wip 2026-08-17 20:24:17 +04:00
sindoring 75600c767f fix 2026-08-13 01:26:42 +04:00
sindoring bfb4aee94b new customization 2026-08-13 00:52:57 +04:00
sindoring 88ed7f639f fix 2026-08-08 12:27:31 +04:00
sindoring 889fe02d1b увеличение сложности подземелий 2026-08-08 11:53:07 +04:00
sindoring 52868e6634 fix import 2026-08-07 14:09:56 +04:00
gasaichan a45158f756 Merge pull request 'feat(transmog): add transmog module' (#2) from feature/collections into master
Reviewed-on: #2
2026-08-07 10:03:54 +03:00
gasaichandesu 4d36c6f702 fix(sql): fix transmog sql being imported in incorrect database 2026-08-07 08:05:47 +04:00
gasaichandesu ed017ab51a feat(transmog): add transmog module 2026-08-07 06:21:22 +04:00
20 changed files with 29467 additions and 12291 deletions
+22 -16
View File
@@ -183,25 +183,25 @@ ACORE_WORLD_DBC_ENFORCE_ITEM_ATTRIBUTES=0
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.
ACORE_WORLD_RATE_CREATURE_RARE_DAMAGE=0.80
ACORE_WORLD_RATE_CREATURE_RARE_DAMAGE=1.00
# Множитель физического урона rare elite.
ACORE_WORLD_RATE_CREATURE_RAREELITE_DAMAGE=0.75
ACORE_WORLD_RATE_CREATURE_RAREELITE_DAMAGE=1.00
# Множитель физического урона world boss.
ACORE_WORLD_RATE_CREATURE_WORLDBOSS_DAMAGE=1
# Множитель spell-урона обычной элиты.
ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_SPELL_DAMAGE=0.80
ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_SPELL_DAMAGE=1.00
# Множитель spell-урона rare.
ACORE_WORLD_RATE_CREATURE_RARE_SPELL_DAMAGE=0.80
ACORE_WORLD_RATE_CREATURE_RARE_SPELL_DAMAGE=1.00
# Множитель spell-урона rare elite.
ACORE_WORLD_RATE_CREATURE_RAREELITE_SPELL_DAMAGE=0.75
ACORE_WORLD_RATE_CREATURE_RAREELITE_SPELL_DAMAGE=1.00
# Множитель spell-урона world boss.
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.
ACORE_AUTOBALANCE_MIN_PLAYERS_HEROIC=1
ACORE_AUTOBALANCE_MIN_PLAYERS_HEROIC=2
# Минимальное число игроков для рейдов normal.
ACORE_AUTOBALANCE_MIN_PLAYERS_RAID=1
@@ -234,23 +234,29 @@ ACORE_AUTOBALANCE_MIN_PLAYERS_RAID=1
# Минимальное число игроков для рейдов heroic.
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.
ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH=1.30
ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH=1.55
# Множитель урона обычных мобов в normal 5ppl.
ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE=1.10
ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE=2.00
# Множитель HP боссов в normal 5ppl.
ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH=1.40
ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH=2.00
# Множитель HP обычных мобов в heroic 5ppl.
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_HEALTH=1.25
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_HEALTH=1.65
# Множитель урона обычных мобов в heroic 5ppl.
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_DAMAGE=1.05
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_DAMAGE=2.20
# Множитель 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
@@ -265,10 +271,10 @@ ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC=0.80
ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC_BOSS_MODIFIER=1.45
# Множитель урона боссов в обычных 5ppl.
ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_DAMAGE=1.00
ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_DAMAGE=2.50
# Множитель урона боссов в heroic 5ppl.
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_DAMAGE=0.90
ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_DAMAGE=2.80
# Общий множитель статов рейда normal.
ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_GLOBAL=0.85
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;
+371
View File
@@ -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-дистрибутивы.
+84
View File
@@ -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 подряд и убедиться, что работает только второй.
+3
View File
@@ -48,6 +48,9 @@ services:
- ./modules:/azerothcore/modules:ro
- ./lua_scripts:/azerothcore/lua_scripts
- ./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:
playerbots-ai-cache:
+1012 -1775
View File
File diff suppressed because it is too large Load Diff
+26646 -10438
View File
File diff suppressed because it is too large Load Diff
+12 -1
View File
@@ -156,7 +156,7 @@ import_mythicplus_sql() {
log "repairing MythicPlus world bootstrap entries"
mysql_exec "acore_world" "
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 item_template_locale WHERE ID = 900100;
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_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() {
log "configuring MoonWell login message"
mysql_exec "acore_auth" "
@@ -362,6 +372,7 @@ import_aoe_loot_sql
import_playerbots_ru_sql
import_encounter_journal_sql
import_store_sql
import_transmog_sql
configure_server_motd
log "custom SQL bootstrap completed"
+167
View File
@@ -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,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.")
+27 -18
View File
@@ -23,35 +23,37 @@ FORCE_PLAYERBOTS="${FORCE_PLAYERBOTS:-${ACORE_PLAYERBOTS_FORCE:-0}}"
: "${ACORE_WORLD_CONSOLE_ENABLE:=0}"
: "${ACORE_WORLD_DBC_ENFORCE_ITEM_ATTRIBUTES:=0}"
: "${ACORE_WORLD_INSTANCE_IGNORE_RAID:=1}"
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_DAMAGE:=0.80}"
: "${ACORE_WORLD_RATE_CREATURE_RARE_DAMAGE:=0.80}"
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_DAMAGE:=0.75}"
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_DAMAGE:=1.00}"
: "${ACORE_WORLD_RATE_CREATURE_RARE_DAMAGE:=1.00}"
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_DAMAGE:=1.00}"
: "${ACORE_WORLD_RATE_CREATURE_WORLDBOSS_DAMAGE:=1}"
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_SPELL_DAMAGE:=0.80}"
: "${ACORE_WORLD_RATE_CREATURE_RARE_SPELL_DAMAGE:=0.80}"
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_SPELL_DAMAGE:=0.75}"
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_SPELL_DAMAGE:=1.00}"
: "${ACORE_WORLD_RATE_CREATURE_RARE_SPELL_DAMAGE:=1.00}"
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_SPELL_DAMAGE:=1.00}"
: "${ACORE_WORLD_RATE_CREATURE_WORLDBOSS_SPELL_DAMAGE:=1}"
: "${ACORE_WORLD_RATE_CREATURE_ELITE_ELITE_HP:=0.75}"
: "${ACORE_WORLD_RATE_CREATURE_RARE_HP:=0.70}"
: "${ACORE_WORLD_RATE_CREATURE_RAREELITE_HP:=0.65}"
: "${ACORE_WORLD_RATE_CREATURE_WORLDBOSS_HP:=1}"
: "${ACORE_AUTOBALANCE_MIN_PLAYERS:=1}"
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_HEROIC:=1}"
: "${ACORE_AUTOBALANCE_MIN_PLAYERS:=2}"
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_HEROIC:=2}"
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID:=1}"
: "${ACORE_AUTOBALANCE_MIN_PLAYERS_RAID_HEROIC:=1}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH:=1.30}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE:=1.10}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH:=1.40}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_HEALTH:=1.25}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_DAMAGE:=1.05}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_HEALTH:=1.35}"
: "${ACORE_AUTOBALANCE_LEVEL_SCALING_FLOOR_DUNGEONS:=1}"
: "${ACORE_AUTOBALANCE_LEVEL_SCALING_FLOOR_HEROIC_DUNGEONS:=1}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEALTH:=1.55}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE:=2.00}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH:=2.00}"
: "${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_BOSS_MODIFIER:=1.35}"
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC:=0.80}"
: "${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC_BOSS_MODIFIER:=1.45}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_DAMAGE:=1.00}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_DAMAGE:=0.90}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_DAMAGE:=2.50}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_HEROIC_BOSS_DAMAGE:=2.80}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_GLOBAL:=0.85}"
: "${ACORE_AUTOBALANCE_STAT_MODIFIER_RAID_DAMAGE:=0.75}"
: "${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.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.
AutoBalance.InflectionPointRaid = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID}
AutoBalance.InflectionPointRaid.BossModifier = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_BOSS_MODIFIER}
AutoBalance.InflectionPointRaidHeroic = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC}
AutoBalance.InflectionPointRaidHeroic.BossModifier = ${ACORE_AUTOBALANCE_INFLECTION_POINT_RAID_HEROIC_BOSS_MODIFIER}
# Moderate solo profile for 5-man dungeons: longer fights with a controlled
# damage increase, while keeping one-player scaling enabled.
# Medium solo profile for 5-man dungeons. A solo player is scaled against a
# 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.Damage = ${ACORE_AUTOBALANCE_STAT_MODIFIER_DAMAGE}
AutoBalance.StatModifier.Boss.Health = ${ACORE_AUTOBALANCE_STAT_MODIFIER_BOSS_HEALTH}
+35
View File
@@ -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) */;
+33
View File
@@ -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) */;
+138 -39
View File
@@ -183,6 +183,7 @@ bool AuthSession::Update()
return false;
_queryProcessor.ProcessReadyCallbacks();
_transactionProcessor.ProcessReadyCallbacks();
return true;
}
@@ -309,7 +310,9 @@ bool AuthSession::HandleLogonChallenge()
// Get the account details from the account table
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_SEL_LOGONCHALLENGE);
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)));
return true;
@@ -387,8 +390,21 @@ void AuthSession::LogonChallengeCallback(PreparedQueryResult result)
uint8 securityFlags = 0;
// Check if a TOTP token is needed
if (!fields[12].IsNull())
_isDeveloperLogin = fields[15].Get<bool>();
_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;
_totpSecret = fields[12].Get<Binary>();
@@ -406,9 +422,19 @@ void AuthSession::LogonChallengeCallback(PreparedQueryResult result)
}
}
_srp6.emplace(_accountInfo.Login,
fields[13].Get<Binary, Acore::Crypto::SRP6::SALT_LENGTH>(),
fields[14].Get<Binary, Acore::Crypto::SRP6::VERIFIER_LENGTH>());
if (_isDeveloperLogin)
{
_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
if (AuthHelper::IsAcceptedClientBuild(_build))
@@ -515,45 +541,77 @@ bool AuthSession::HandleLogonProof()
// 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";
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LOGONPROOF);
stmt->SetData(0, _sessionKey);
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, M2 = Acore::Crypto::SRP6::GetSessionVerifier(logonProof->A, logonProof->clientM, _sessionKey)](PreparedQueryResult const&)
Acore::Crypto::SHA1::Digest serverProof = Acore::Crypto::SRP6::GetSessionVerifier(logonProof->A, logonProof->clientM, _sessionKey);
if (_isDeveloperLogin)
{
// Finish SRP6 and send the final result to the client
ByteBuffer packet;
if (_expversion & POST_BC_EXP_FLAG) // 2.x and 3.x clients
LoginDatabasePreparedStatement* stmt = LoginDatabase.GetPreparedStatement(LOGIN_UPD_LOGONPROOF);
stmt->SetData(0, _sessionKey);
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;
proof.M2 = M2;
proof.cmd = AUTH_LOGON_PROOF;
proof.error = 0;
proof.AccountFlags = _accountInfo.Flags;
proof.SurveyId = 0;
proof.LoginFlags = 0; // 0x1 = has account message
SendLogonProofSuccess(serverProof);
}));
}
else
{
ASSERT(_launcherTicketGeneration);
std::array<uint8, 16> claimNonce = Acore::Crypto::GetRandomBytes<16>();
packet.resize(sizeof(proof));
std::memcpy(packet.contents(), &proof, sizeof(proof));
}
else
LoginDatabaseTransaction transaction = LoginDatabase.BeginTransaction();
LoginDatabasePreparedStatement* consume = LoginDatabase.GetPreparedStatement(LOGIN_INS_LAUNCHER_TICKET_CONSUMPTION);
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;
proof.M2 = M2;
proof.cmd = AUTH_LOGON_PROOF;
proof.error = 0;
proof.unk2 = 0x00;
if (!success)
{
SendLogonProofFailure();
return;
}
packet.resize(sizeof(proof));
std::memcpy(packet.contents(), &proof, sizeof(proof));
}
LoginDatabasePreparedStatement* claim = LoginDatabase.GetPreparedStatement(LOGIN_SEL_LAUNCHER_TICKET_CLAIM);
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);
_status = STATUS_AUTHED;
}));
SendLogonProofSuccess(serverProof);
}));
});
}
}
else
{
@@ -618,6 +676,47 @@ bool AuthSession::HandleLogonProof()
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()
{
_status = STATUS_CLOSED;
@@ -27,6 +27,7 @@
#include "QueryResult.h"
#include "SRP6.h"
#include "Socket.h"
#include "Transaction.h"
#include <boost/asio/ip/tcp.hpp>
using boost::asio::ip::tcp;
@@ -89,10 +90,14 @@ private:
void LogonChallengeCallback(PreparedQueryResult result);
void ReconnectChallengeCallback(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);
Optional<Acore::Crypto::SRP6> _srp6;
Optional<std::array<uint8, 16>> _launcherTicketGeneration;
bool _isDeveloperLogin = false;
SessionKey _sessionKey = {};
std::array<uint8, 16> _reconnectProof = {};
@@ -106,6 +111,7 @@ private:
uint8 _expversion;
QueryCallbackProcessor _queryProcessor;
AsyncCallbackProcessor<TransactionCallback> _transactionProcessor;
};
#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, "
"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, "
"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 "
"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 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);
PrepareStatement(LOGIN_SEL_RECONNECTCHALLENGE,
"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_banned ab ON ab.id = a.id AND ab.active = 1 "
"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, "
"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 = ? "
"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_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);
@@ -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_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_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_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);
@@ -41,6 +41,10 @@ enum LoginDatabaseStatements : uint32
LOGIN_DEL_ACCOUNT_BANNED,
LOGIN_UPD_LOGON,
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_RECONNECTCHALLENGE,
LOGIN_UPD_FAILEDLOGINS,