From 13747727cf2cfa03553315367a4677258e5024d5 Mon Sep 17 00:00:00 2001 From: sindoring Date: Mon, 11 May 2026 18:35:47 +0400 Subject: [PATCH] taxi builder --- .claude/settings.json | 42 + .../rev_1778279831177904414.sql | 61 + import-custom-sql.sh | 23 + modules/mod-taxi-builder/LICENSE | 4 + modules/mod-taxi-builder/README.md | 753 +++++++++ modules/mod-taxi-builder/conf/conf.sh.dist | 1 + modules/mod-taxi-builder/include.sh | 8 + .../mod-taxi-builder/src/cs_taxi_builder.cpp | 1395 +++++++++++++++++ modules/mod-taxi-builder/src/cs_taxi_log.cpp | 163 ++ .../src/mod_taxi_builder_loader.cpp | 16 + tools/moonwell-taxi/.venv/bin/python | 1 + tools/moonwell-taxi/.venv/bin/python3 | 1 + tools/moonwell-taxi/.venv/bin/python3.12 | 1 + tools/moonwell-taxi/.venv/lib64 | 1 + tools/moonwell-taxi/.venv/pyvenv.cfg | 5 + tools/moonwell-taxi/README.md | 87 + .../__pycache__/moonwell_taxi.cpython-312.pyc | Bin 0 -> 47472 bytes tools/moonwell-taxi/bin/moonwell-taxi | 4 + tools/moonwell-taxi/moonwell_taxi.py | 958 +++++++++++ tools/moonwell-taxi/redeploy.sh | 53 + tools/moonwell-taxi/requirements.txt | 1 + 21 files changed, 3578 insertions(+) create mode 100644 .claude/settings.json create mode 100644 data/sql/updates/pending_db_world/rev_1778279831177904414.sql create mode 100644 modules/mod-taxi-builder/LICENSE create mode 100644 modules/mod-taxi-builder/README.md create mode 100644 modules/mod-taxi-builder/conf/conf.sh.dist create mode 100644 modules/mod-taxi-builder/include.sh create mode 100644 modules/mod-taxi-builder/src/cs_taxi_builder.cpp create mode 100644 modules/mod-taxi-builder/src/cs_taxi_log.cpp create mode 100644 modules/mod-taxi-builder/src/mod_taxi_builder_loader.cpp create mode 120000 tools/moonwell-taxi/.venv/bin/python create mode 120000 tools/moonwell-taxi/.venv/bin/python3 create mode 120000 tools/moonwell-taxi/.venv/bin/python3.12 create mode 120000 tools/moonwell-taxi/.venv/lib64 create mode 100644 tools/moonwell-taxi/.venv/pyvenv.cfg create mode 100644 tools/moonwell-taxi/README.md create mode 100644 tools/moonwell-taxi/__pycache__/moonwell_taxi.cpython-312.pyc create mode 100644 tools/moonwell-taxi/bin/moonwell-taxi create mode 100644 tools/moonwell-taxi/moonwell_taxi.py create mode 100644 tools/moonwell-taxi/redeploy.sh create mode 100644 tools/moonwell-taxi/requirements.txt diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..7cd4714 --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,42 @@ +{ + "permissions": { + "allow": [ + "Bash(grep -rn \"CREATE TABLE.*\\\\`game_tele\\\\`\" /home/sindo/moonwell-core/data/sql/base/db_world/)", + "Bash(grep -rn \"CREATE TABLE.*\\\\`waypoint_data\\\\`\\\\|CREATE TABLE.*\\\\`creature_addon\\\\`\" /home/sindo/moonwell-core/data/sql/base/db_world/)", + "Bash(bash import-custom-sql.sh)", + "Bash(grep -rn \"WorldDatabase\\\\.Execute\\(\\\\\".*{}.*\\\\\"\\\\|WorldDatabase\\\\.DirectExecute\\(\\\\\".*{}\\\\|WorldDatabase\\\\.Execute\\(\\\\\".*\\\\\".*,\" /home/sindo/moonwell-core/src/server/)", + "Bash(grep -n \"enum LocaleConstant\\\\|LocaleConstant\\\\s*$\\\\|LocaleConstant{\" /home/sindo/moonwell-core/src/server/shared/SharedDefines.h /home/sindo/moonwell-core/src/common/Common.h)", + "Bash(chmod +x /home/sindo/moonwell-core/tools/moonwell-taxi/bin/moonwell-taxi)", + "Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiNodes.dbc --limit 3)", + "Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiPath.dbc --limit 3)", + "Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py dump-vanilla /home/sindo/moonwell-core/TaxiPathNode.dbc --limit 3)", + "Bash(python3 -c \"import pymysql; print\\(pymysql.__version__\\)\")", + "Bash(python3 -m pip show pymysql)", + "Bash(python3 -m venv /home/sindo/moonwell-core/tools/moonwell-taxi/.venv)", + "Bash(/home/sindo/moonwell-core/tools/moonwell-taxi/.venv/bin/python -m pip install --quiet -r /home/sindo/moonwell-core/tools/moonwell-taxi/requirements.txt)", + "Bash(/home/sindo/moonwell-core/tools/moonwell-taxi/.venv/bin/python -c \"import pymysql; print\\('ok', pymysql.__version__\\)\")", + "Bash(apt-cache search *)", + "Read(//usr/lib/python3/dist-packages/**)", + "Bash(sudo apt install -y python3-pymysql)", + "Bash(python3 -c \"import pymysql; print\\('OK', pymysql.__version__\\)\")", + "Bash(python3 /home/sindo/moonwell-core/tools/moonwell-taxi/moonwell_taxi.py export-dbc)", + "Bash(BUILDKIT_PROGRESS=plain docker compose build ac-db-import)", + "Bash(tee /tmp/dbimport-build.log)", + "Bash(docker volume *)", + "Bash(chmod +x /home/sindo/moonwell-core/tools/moonwell-taxi/redeploy.sh)", + "Bash(grep -rn 'PSendSysMessage\\(\"[^\"]*{[^\"]*\",' /home/sindo/moonwell-core/src/server/scripts/Commands/)", + "Bash(grep -n \"class taxi_builder_commandscript\\\\|^[[:space:]]*}\\\\?[[:space:]]*$\\\\|namespace\" /home/sindo/moonwell-core/modules/mod-taxi-builder/src/cs_taxi_builder.cpp)", + "Bash(python3 tools/moonwell-taxi/moonwell_taxi.py export-dbc)", + "Bash(python3 tools/moonwell-taxi/moonwell_taxi.py thin --path 90000 --min-yd 50 --dry-run)", + "Bash(python3 -)", + "Bash(cp var/client-data-vanilla/Taxi*.dbc var/client-data-live/dbc/)", + "Bash(bash tools/moonwell-taxi/redeploy.sh)", + "Bash(cp /home/sindo/moonwell-core/var/client-data-vanilla/Taxi*.dbc /home/sindo/moonwell-core/var/client-data-live/dbc/ *)", + "Bash(cp /home/sindo/moonwell-core/build/taxi/export/server/dbc/Taxi*.dbc /home/sindo/moonwell-core/var/client-data-live/dbc/ *)" + ], + "additionalDirectories": [ + "/home/sindo/moonwell-core/var/client-data-vanilla", + "/home/sindo/moonwell-core/build/taxi/export/server/dbc" + ] + } +} diff --git a/data/sql/updates/pending_db_world/rev_1778279831177904414.sql b/data/sql/updates/pending_db_world/rev_1778279831177904414.sql new file mode 100644 index 0000000..cb7b643 --- /dev/null +++ b/data/sql/updates/pending_db_world/rev_1778279831177904414.sql @@ -0,0 +1,61 @@ +-- +-- Taxi Route Builder: storage for custom flight nodes, paths and waypoints. +-- DBC files (TaxiNodes / TaxiPath / TaxiPathNode) are GENERATED from these +-- tables by the moonwell-taxi CLI. These tables are the source of truth. +-- +-- ID space (enforced by the in-game module, not by this schema): +-- * custom_taxi_nodes.id >= 1000 (vanilla WotLK uses up to ~313) +-- * custom_taxi_paths.id >= 90000 (vanilla uses up to ~700) +-- from_taxi_node_id / to_taxi_node_id may reference EITHER a row in +-- custom_taxi_nodes OR a vanilla TaxiNodes.dbc id, so no FK is declared. +-- + +CREATE TABLE IF NOT EXISTS `custom_taxi_nodes` ( + `id` int unsigned NOT NULL, + `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL, + `map_id` smallint unsigned NOT NULL DEFAULT '0', + `position_x` float NOT NULL DEFAULT '0', + `position_y` float NOT NULL DEFAULT '0', + `position_z` float NOT NULL DEFAULT '0', + `mount_alliance` int unsigned NOT NULL DEFAULT '0', + `mount_horde` int unsigned NOT NULL DEFAULT '0', + `created_by` bigint unsigned NOT NULL DEFAULT '0' COMMENT 'character GUID of GM who created the node', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Taxi Builder: custom flight masters (source for TaxiNodes.dbc)'; + +CREATE TABLE IF NOT EXISTS `custom_taxi_paths` ( + `id` int unsigned NOT NULL, + `from_taxi_node_id` int unsigned NOT NULL, + `to_taxi_node_id` int unsigned NOT NULL, + `cost` int unsigned NOT NULL DEFAULT '0' COMMENT 'flight cost in copper', + `is_enabled` tinyint(1) unsigned NOT NULL DEFAULT '1', + `created_by` bigint unsigned NOT NULL DEFAULT '0', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + KEY `idx_from_to` (`from_taxi_node_id`, `to_taxi_node_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Taxi Builder: custom directed paths (source for TaxiPath.dbc)'; + +CREATE TABLE IF NOT EXISTS `custom_taxi_path_nodes` ( + `id` int unsigned NOT NULL AUTO_INCREMENT, + `path_id` int unsigned NOT NULL, + `node_index` int unsigned NOT NULL COMMENT '0-based, contiguous within a path', + `map_id` smallint unsigned NOT NULL DEFAULT '0', + `position_x` float NOT NULL DEFAULT '0', + `position_y` float NOT NULL DEFAULT '0', + `position_z` float NOT NULL DEFAULT '0', + `flags` int unsigned NOT NULL DEFAULT '0' COMMENT 'TaxiPathNode flags (teleport=1, stop=2, ...)', + `delay` int unsigned NOT NULL DEFAULT '0', + `arrival_event_id` int unsigned NOT NULL DEFAULT '0', + `departure_event_id` int unsigned NOT NULL DEFAULT '0', + `comment` varchar(255) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci NOT NULL DEFAULT '', + `created_at` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`id`), + UNIQUE KEY `uniq_path_index` (`path_id`, `node_index`), + KEY `idx_path` (`path_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci + COMMENT='Taxi Builder: waypoints of custom paths (source for TaxiPathNode.dbc)'; diff --git a/import-custom-sql.sh b/import-custom-sql.sh index ed58559..98abc2b 100644 --- a/import-custom-sql.sh +++ b/import-custom-sql.sh @@ -234,6 +234,28 @@ import_aoe_loot_sql() { import_sql_file "acore_world" "$aoe_loot_sql_file" } +import_taxi_builder_sql() { + # Source-of-truth tables for the Taxi Route Builder. The migration also + # lives in data/sql/updates/pending_db_world/ for AzerothCore DBUpdater, + # but we apply it eagerly here so deploy flows that don't rely on the + # worldserver-side updater still bootstrap correctly. + local taxi_builder_sql_file="$ROOT_DIR/data/sql/updates/pending_db_world/rev_1778279831177904414.sql" + + if [[ ! -f "$taxi_builder_sql_file" ]]; then + log "taxi-builder migration not found, skipped" + return 0 + fi + + if table_exists "acore_world" "custom_taxi_nodes" \ + && table_exists "acore_world" "custom_taxi_paths" \ + && table_exists "acore_world" "custom_taxi_path_nodes"; then + log "taxi-builder tables already exist, skipping" + return 0 + fi + + import_sql_file "acore_world" "$taxi_builder_sql_file" +} + if (($# > 0)); then case "$1" in -h|--help) @@ -260,6 +282,7 @@ fi import_mythicplus_sql import_aoe_loot_sql +import_taxi_builder_sql import_store_sql log "custom SQL bootstrap completed" diff --git a/modules/mod-taxi-builder/LICENSE b/modules/mod-taxi-builder/LICENSE new file mode 100644 index 0000000..0705dc6 --- /dev/null +++ b/modules/mod-taxi-builder/LICENSE @@ -0,0 +1,4 @@ +SPDX-License-Identifier: AGPL-3.0-or-later + +This module is licensed under the GNU Affero General Public License v3 or +later. See https://www.gnu.org/licenses/agpl-3.0.html for the full text. diff --git a/modules/mod-taxi-builder/README.md b/modules/mod-taxi-builder/README.md new file mode 100644 index 0000000..00d69f6 --- /dev/null +++ b/modules/mod-taxi-builder/README.md @@ -0,0 +1,753 @@ +# mod-taxi-builder + +GM-инструмент для создания собственных точек полёта и маршрутов +прямо в игре на сервере MoonWell (AzerothCore 3.3.5a). Не требует +ручного редактирования DBC: все данные складываются в три таблицы +`acore_world`, а внешний CLI [moonwell-taxi](../../tools/moonwell-taxi/) +собирает из них `TaxiNodes.dbc`, `TaxiPath.dbc` и `TaxiPathNode.dbc` +для серверной `dbc/`-папки и клиентского патча. + +Все команды требуют права `SEC_ADMINISTRATOR` (стандартный GM-уровень +3+). + +--- + +## Содержание + +- [Установка](#установка) +- [Полный сценарий: новый маршрут с нуля](#полный-сценарий-новый-маршрут-с-нуля) +- [Команды](#команды) + - [`.taxi node`](#taxi-node) + - [`.taxi path`](#taxi-path) + - [`.taxi record`](#taxi-record) + - [`.taxi markers`](#taxi-markers) + - [`.taxi export dbc`](#taxi-export-dbc) +- [Лимит TaxiMask и распределение id](#лимит-taximask-и-распределение-id) +- [Правила валидации](#правила-валидации) +- [Прореживание точек: `moonwell-taxi thin`](#прореживание-точек-moonwell-taxi-thin) +- [Структура БД](#структура-бд) +- [Связь с CLI moonwell-taxi](#связь-с-cli-moonwell-taxi) +- [Известные ограничения](#известные-ограничения) +- [FAQ / типичные ошибки](#faq--типичные-ошибки) + +--- + +## Установка + +1. Клонируйте репозиторий ядра и убедитесь, что папка + `modules/mod-taxi-builder/` присутствует. +2. Примените миграцию схемы. Самый простой способ — через + [import-custom-sql.sh](../../import-custom-sql.sh): + + ```bash + docker compose up -d ac-database + bash import-custom-sql.sh + ``` + + В `acore_world` появятся три таблицы: `custom_taxi_nodes`, + `custom_taxi_paths`, `custom_taxi_path_nodes`. Скрипт идемпотентен — + повторный запуск пропустит уже созданные таблицы. + +3. Соберите ядро со включенными модулями (значение по умолчанию): + + ```bash + mkdir -p build && cd build + cmake .. -DCMAKE_INSTALL_PREFIX=$HOME/azeroth-server \ + -DCMAKE_BUILD_TYPE=RelWithDebInfo \ + -DSCRIPTS=static -DMODULES=static + make -j$(nproc) && make install + ``` + +4. Перезапустите worldserver. На старте появится строка о регистрации + `taxi_builder_commandscript`. + +5. (Один раз) поставьте `PyMySQL` для CLI: + + ```bash + sudo apt install -y python3-pymysql + ``` + +--- + +## Полный сценарий: новый маршрут с нуля + +Допустим, GM хочет добавить кастомный маршрут «Златоземье → Штормград» +(вернее, «новая точка → существующий узел Штормграда `id=2`»). + +``` +# 1. Встаём ровно туда, где должна быть новая точка полёта. +.taxi node create "Златоземье" +# -> [taxi-builder] node created: id=441 name="Златоземье" map=0 ... + +# 2. Создаём маршрут от новой точки к Штормграду (vanilla node id=2), +# стоимость — 10 медяков. +.taxi path create 441 2 10 +# -> [taxi-builder] path created: id=1979 441 (Златоземье) -> 2 (Stormwind) ... + +# 3. Запускаем запись маршрута. +.taxi record start 1979 +# -> [taxi-builder] recording started for path 1979 (existing points: 0) + +# 4. Идём/летим по будущему маршруту. На каждой ключевой точке: +.taxi record point +# (или с комментарием — пригодится для последующего отчёта) +.taxi record point "взлёт над таверной" +.taxi record point +.taxi record point +# ...записываем 15-25 точек... + +# Если оступились — откат последней: +.taxi record undo + +# 5. Завершаем запись. Точки сохраняются. +.taxi record finish +# -> path 1979 now has 18 waypoints. + +# 6. Проверяем маршрут на типовые проблемы. +.taxi path validate 1979 +# -> Validation: 0 errors, 2 warnings — OK + +# (Опционально) Расставить визуальные маркеры на всех точках, обойти их пешком: +.taxi markers spawn 1979 +# ... осмотр ... +.taxi markers clear 1979 + +# 7. Из консоли (или из чата) запускаем экспорт DBC. +moonwell-taxi export-dbc + +# 8. Кладём серверные DBC в папку сервера и перезапускаем worldserver. +# 9. Кладём клиентские DBC в патч-MPQ и перезапускаем клиент (или удаляем Cache). +# 10. На месте мастера полётов в Златоземье спавним NPC с npcflag=flight master +# и фракцией Альянса/Орды (для MVP — стандартные команды AzerothCore .npc add). +# 11. Подходим к нему, выбираем путь, летим. +``` + +После шага 9 точка появится у мастеров полётов и игроки смогут летать +по записанному маршруту. + +--- + +## Команды + +### `.taxi node` + +Управление точками полёта (записи в `custom_taxi_nodes`). + +#### `.taxi node create "Name"` + +Создаёт новую такситочку из текущей позиции GM. Работает только из +игры (нужны координаты). + +- `id` подбирается автоматически из безопасного диапазона (`>= 441`, + избегая конфликтов с vanilla TaxiNodes ~313). +- `mount_alliance` и `mount_horde` определяются автоматически: модуль + смотрит, какие mount-creature чаще всего используются у vanilla точек + на текущей карте, и подставляет их. Если карта неизвестна — fallback + `541/541` (как в примере ТЗ). +- Имя сохраняется как одна строка и при экспорте в DBC дублируется во + все 16 локалей (отображается на любом клиенте). + +``` +.taxi node create "Златоземье" +[taxi-builder] node created: id=441 name="Златоземье" map=0 + xyz=(-9445.77, 79.40, 57.38) mounts=A:1572/H:295 +``` + +#### `.taxi node list [filter]` + +Печатает список точек: сначала custom (`custom_taxi_nodes`), затем +vanilla (`sTaxiNodesStore`). Без аргумента дамп ограничен 50 строками +(в чате не должно быть спама). С `filter` — подстрочный поиск без +учёта регистра. Доступна и из консоли. + +``` +.taxi node list Storm +[vanilla] 2 Stormwind, Elwynn @ map 0 (-9446.5, 64.4, 56.0) +[vanilla] 38 Stonewatch Tower, Redridge @ map 0 (-9241.3, -2280.7, 65.0) +Shown 2 (cap 50). Custom: 1, Vanilla: 365. +``` + +#### `.taxi node free` + +Печатает все свободные TaxiNode id в диапазоне `1..448`, сгруппированные +в непрерывные подряд-идущие отрезки. Полезно перед `.taxi node create` +посмотреть, сколько слотов осталось, и до создания убедиться, что +auto-allocator не упрётся. Подробности — в разделе +[Лимит TaxiMask и распределение id](#лимит-taximask-и-распределение-id). + +```text +.taxi node free +Free TaxiNode ids in 1..448: 83 total (7 above-vanilla 441..448, 76 in vanilla gaps). + 338..339 (2) + ... + 441..448 (8) +``` + +#### `.taxi node goto ` + +Телепортирует GM к такситочке (custom или vanilla — что найдётся +первым). Удобно для быстрой проверки, что точка стоит, где надо. + +```text +.taxi node goto 441 +[taxi-builder] teleported to custom node 441 (Златоземье) +``` + +--- + +### `.taxi path` + +Управление маршрутами (записи в `custom_taxi_paths`). + +#### `.taxi path create ` + +Создаёт направленный маршрут между двумя taxi-нодами. Любой конец +может быть как custom-нодом, так и vanilla — модуль ищет по обоим +источникам и валит, если ID нигде нет. Если уже есть включённый путь +с тем же `from→to` — отказ (нельзя случайно создать дубль). + +- `id` подбирается автоматически из диапазона `>= 1979`. +- `cost` хранится в медяках. +- `is_enabled` ставится в `1`. + +``` +.taxi path create 441 2 10 +[taxi-builder] path created: id=1979 441 (Златоземье) -> 2 (Stormwind) cost=10 copper +Now record waypoints with: .taxi record start 1979 -> .taxi record point ... -> .taxi record finish +``` + +#### `.taxi path reverse ` + +Создаёт обратный путь из существующего: + +1. Делает новый `TaxiPath` `to → from` с тем же `cost`. +2. Копирует все `TaxiPathNode` исходного пути в обратном порядке, + пересчитывая `node_index` от `0`. +3. Печатает предупреждение из ТЗ: ручную проверку посадки никто не + отменял. + +``` +.taxi path reverse 1979 +[taxi-builder] reversed path 1979 (441->2) into new path 1980 (2->441) with 18 waypoints. +WARNING: reverse paths are auto-generated. Check landing waypoints by hand + (`.taxi path goto 1980 `)... +``` + +#### `.taxi path list` + +Все custom-маршруты с резолвом имён узлов и счётчиком точек. + +``` +.taxi path list +1979 441 (Златоземье) -> 2 (Stormwind, Elwynn) cost=10 pts=18 enabled +1980 2 (Stormwind, Elwynn) -> 441 (Златоземье) cost=10 pts=18 enabled +Total: 2 custom paths. +``` + +#### `.taxi path report ` + +Подробный отчёт по маршруту в формате секции 5.3 ТЗ. + +``` +.taxi path report 1979 +PathID: 1979 (enabled) +From: 441 Златоземье [custom] +To: 2 Stormwind, Elwynn [vanilla] +Cost: 10 copper +Points: 18 +Total distance: 1842.3 yd +Min segment: 41.7 yd Max segment: 95.4 yd +Last point distance to ToTaxiNode: 4.8 yd +``` + +#### `.taxi path validate ` + +Применяет правила секции 4 ТЗ. См. подробности в разделе +[Правила валидации](#правила-валидации). + +``` +.taxi path validate 1979 +[warn] segment 7->8 is 38.2 yd (< 40 yd, may be filtered by IsNodeIncludedInShortenedPath) +[warn] sharp turn at NodeIndex 12: 89 deg (> 75). Consider adding intermediate points. +Validation: 0 errors, 2 warnings — OK +``` + +При наличии errors статус — `FAIL`. Маршрут с errors не пропустит +`.taxi path test`. + +#### `.taxi path goto ` + +Телепорт к конкретной точке маршрута. Удобно для шага «обход маркеров». + +``` +.taxi path goto 1979 7 +[taxi-builder] teleported to path 1979, NodeIndex 7 map=0 (-9095.00, 230.00, 180.00) +``` + +#### `.taxi path test ` + +«Сухой запуск»: запускает валидацию (отказ при errors), затем +телепортирует GM на первую точку маршрута, развернув лицом ко второй. +Реальный полёт по таксиспрайнам в этой версии возможен только после +прогона `moonwell-taxi export-dbc` и перезапуска worldserver+клиента. + +``` +.taxi path test 1979 + [warn] segment 7->8 is 38.2 yd ... +[taxi-builder] dropped at first waypoint of path 1979 (18 total). +Step through with `.taxi path goto 1979 `. +Real taxi flight needs `moonwell-taxi export-dbc` + worldserver/client restart. +``` + +--- + +### `.taxi record` + +Запись точек маршрута. Запись — *сессия в памяти*, привязанная к GM-у: +рестарт worldserver очистит сессию, но уже записанные waypoint-ы в БД +останутся, и сессию можно продолжить через `.taxi record start `. + +#### `.taxi record start ` + +Начинает запись для существующего custom-пути. Запоминает baseline — +число точек, которые были у пути до старта (для корректного `cancel`). + +``` +.taxi record start 1979 +[taxi-builder] recording started for path 1979 (existing points: 0). +Use `.taxi record point ["comment"]` and `.taxi record finish` when done. +``` + +#### `.taxi record point [comment]` + +Записывает следующую точку с координатами и map_id GM-а. `node_index` +автоматически = `MAX(node_index) + 1` (или 0 для первой). + +Опциональный комментарий пишется в `comment` колонку (до 255 символов). + +``` +.taxi record point +[taxi-builder] path=1979 point #0 map=0 (-9445.77, 79.40, 57.38) + +.taxi record point "разворот над озером" +[taxi-builder] path=1979 point #1 map=0 (-9395.00, 80.00, 70.00) // разворот над озером +``` + +#### `.taxi record undo` + +Удаляет последнюю записанную в этой сессии точку. Не даёт уйти ниже +baseline (точки, существовавшие до `start`, защищены). + +``` +.taxi record undo +[taxi-builder] removed point #17 from path 1979. +``` + +#### `.taxi record finish` + +Закрывает сессию. Точки остаются в БД. Печатает счётчики. + +``` +.taxi record finish +[taxi-builder] recording finished. Path 1979 now has 18 waypoints (added 18 this session). +Run `.taxi path report 1979` or `.taxi path validate 1979` to review. +``` + +#### `.taxi record cancel` + +Откатывает текущую сессию: удаляет ровно те точки, которые добавил +этот `start ... point ...`. Существовавшие до сессии waypoint-ы не +трогаются — это полезно при правке уже готового маршрута. + +``` +.taxi record cancel +[taxi-builder] recording cancelled for path 1979; rolled back to 5 waypoints. +``` + +--- + +### `.taxi markers` + +Временные визуальные маркеры на waypoint-ах. По умолчанию используется +GO `178559` (Banner Stand of Argent Crusade) — он мелкий, виден всем +и есть в стоковой `gameobject_template`. При желании смените +`MARKER_GO_ENTRY` в [src/cs_taxi_builder.cpp](src/cs_taxi_builder.cpp). + +#### `.taxi markers spawn ` + +Спавнит маркеры на всех точках маршрута, лежащих на текущей карте +GM-а. Точки на других континентах пропускаются (грид всё равно не +загружен — было бы бесполезно). + +Маркеры держатся 24 часа или до явного `clear`. Повторный `spawn` +сначала чистит предыдущие, чтобы не плодить дубли. + +``` +.taxi markers spawn 1979 +[taxi-builder] spawned 18 markers for path 1979 on map 0 (0 skipped on other maps). + Auto-despawn in 86400 s. Use `.taxi markers clear 1979` to remove now. +``` + +#### `.taxi markers clear ` + +Удаляет все маркеры данного пути. + +``` +.taxi markers clear 1979 +[taxi-builder] cleared 18 markers for path 1979. +``` + +--- + +### `.taxi export dbc` + +Внутри игры команда не делает экспорт сама — она лишь напоминает +запустить внешний CLI. Это сделано специально: генерация DBC, sha256 +и копирование файлов — задача внешнего инструмента, чтобы не нагружать +runtime worldserver-а. + +``` +.taxi export dbc +[taxi-builder] export dbc stub: run `moonwell-taxi export-dbc` from CLI +``` + +См. раздел [Связь с CLI moonwell-taxi](#связь-с-cli-moonwell-taxi). + +--- + +## Лимит TaxiMask и распределение id + +`TaxiNode.id` ограничен сверху значением **448**. Это hardcoded в +`src/server/shared/DataStores/DBCStructure.h:2284`: + +```cpp +static constexpr std::size_t TaxiMaskSize = 14; +typedef std::array TaxiMask; +``` + +14 × 32 = 448 бит. По одному биту на каждую такситочку: «доступна +ли в taxi-сети» и «открыл ли её игрок». Сервер в +`ObjectMgr::GetNearestTaxiNode` пропускает любой узел с +`(id - 1) / 32 >= 14`, то есть `id > 448`. + +Симптом нарушения: GM создал точку `id = 441`, custom NPC появился, +но при выборе destination сервер берёт ближайший **vanilla** узел +вместо нашего, строит другую трассу — игрок «крутится по дороге» +и в конце сваливается. Видимых ошибок в логе нет. + +Поднять потолок без патча клиента нельзя: пакет `SMSG_SHOWTAXINODES` +шлёт ровно 14 uint32, клиент 3.3.5a читает фиксированный размер. + +### Что внутри 1..448 + +В реальной vanilla-DBC проекта используется до `id = 440`, и часть +номеров в этом диапазоне пустует. Вместе с диапазоном выше vanilla +(`441..448`) даёт ~84 свободных слота. Команда `.taxi node free` +показывает текущую картину: + +```text +.taxi node free +Free TaxiNode ids in 1..448: 84 total (8 above-vanilla 441..448, 76 in vanilla gaps). + 338..339 (2) + 343..347 (5) + 349..351 (3) + 354..356 (3) + 360..374 (15) + 378 + 385..391 (7) + 396..403 (8) + 406..437 (32) + 441..448 (8) +``` + +`.taxi node create` использует двухзонный авто-выбор: + +1. Сначала ищет первый свободный id в `441..448` (выше vanilla, + всегда безопасно). +2. Если эта зона исчерпана — берёт первый свободный id в дырах + `1..440`. + +«Свободный» означает: id не загружен в `sTaxiNodesStore` +(merged DBC на сервере) **и** не присутствует в `custom_taxi_nodes` +(может быть ещё не задеплоен в DBC). + +### Что делать при исчерпании всех 84 слотов + +- Освободить слот: удалить лишнюю строку из `custom_taxi_nodes` + (плюс зависимости в `custom_taxi_paths`). +- Перейти на бинарный патч `Wow.exe` под расширенный `TaxiMaskSize` + (Cataclysm-эра приватки делают так — увеличивают до 32 uint32 = + 1024 ноды). Это вне scope модуля: нужен патч exe + изменение + `TaxiMaskSize` в ядре + раскатка патча игрокам. +- Использовать «фейковые» полёты через скриптового NPC и `MoveSplinePath`, + без записи в TaxiMask. Не показывается на карте как обычная точка + полёта, но id неограничены. Тоже отдельный модуль. + +--- + +## Правила валидации + +`.taxi path validate ` применяет следующие правила +(пороги — константы в [src/cs_taxi_builder.cpp](src/cs_taxi_builder.cpp), +секция «Thresholds per spec section 4»): + +| # | Правило | Уровень | +| --- | --- | --- | +| 4.1 | `from`/`to`-нод существует (custom или vanilla) | error | +| 4.1 | у пути есть хотя бы одна waypoint | error | +| 4.1 | `node_index` идёт подряд от 0 | error | +| 4.2 | минимум 3 точки | error | +| 4.2 | рекомендация: 12+ точек | warning | +| 4.3 | сегмент `> 200 yd` (XY) | error | +| 4.3 | сегмент `> 120 yd` (XY) | warning | +| 4.3 | сегмент `< 40 yd` (XY) | warning (`IsNodeIncludedInShortenedPath` режет) | +| 4.3 | сегмент пересекает границу карты | error (полёт между континентами невозможен) | +| 4.4 | первая точка `> 20 yd` от `FromTaxiNode` | warning | +| 4.4 | последняя точка `> 20 yd` от `ToTaxiNode` | warning | +| 4.4 | первая/последняя точка на другой карте, чем нод | error | +| 4.5 | модуль абсолютной разницы Z последней точки и `ToTaxiNode > 30 yd` | warning (риск падения) | +| 4.6 | поворот `> 75°` в плоскости XY между сегментами | warning | + +`.taxi path test` отказывается работать, если есть хотя бы один error. +Warnings — индикативны, не блокируют. + +--- + +## Прореживание точек: `moonwell-taxi thin` + +Самая частая поломка свежезаписанного маршрута: в полёте таксИ +«крутится по дороге» и сбрасывает игрока на кладбище. Причина — +большинство сегментов короче 40 yd. AzerothCore при построении +spline-а вызывает `IsNodeIncludedInShortenedPath` (`SKIP_SPLINE_POINT_DISTANCE_SQ = 40 * 40`) +и выкидывает waypoint-ы, которые ближе ~40 yd друг к другу. Если у +вас 68 точек, среднее расстояние 12 yd — после фильтрации остаётся +3-5 точек, по которым spline крутится как угодно. + +Симптом виден прямо в `.taxi path validate `: + +``` +[warn] segment 19->20 is 12.1 yd (< 40 yd, may be filtered by IsNodeIncludedInShortenedPath) +[warn] segment 20->21 is 12.7 yd (< 40 yd, ...) +... +Validation: 0 errors, 67 warnings — OK +``` + +Чинить руками удалением точек по одной — больно. Внешний CLI имеет +подкоманду `thin`, которая делает это безопасно: пишет бэкап, +удаляет старые waypoint-ы и записывает прореженные с пересчитанным +`node_index` от 0. + +```bash +# 1. посмотреть, что получится, без записи в БД +python3 tools/moonwell-taxi/moonwell_taxi.py thin --path 1979 --min-yd 50 --dry-run + +# 2. реально применить +python3 tools/moonwell-taxi/moonwell_taxi.py thin --path 1979 --min-yd 50 +``` + +Параметры: + +- `--path ` — обязательный, какой `custom_taxi_paths.id` чистить. +- `--min-yd ` — минимальное XY-расстояние между соседними + оставленными точками. Дефолт `50`. Для длинных прямых смело + ставьте `60-80`, для извилистых — `40-50`. +- `--dry-run` — показать план без модификации БД. + +Алгоритм: + +1. Первая waypoint всегда сохраняется. +2. Каждая последующая сравнивается с последней оставленной по XY. + Если ≥ `--min-yd` — сохраняется, иначе пропускается. +3. Последняя waypoint всегда сохраняется (анкер посадки). +4. Если предпоследняя получилась слишком близко к последней — её + тоже выкидывают. Так не возникает «маленький хвост» на финише. +5. Все ID `node_index` пересчитываются от `0` без пропусков. +6. Перед удалением исходные строки копируются в + `custom_taxi_path_nodes_backup__` — откатить + можно прямым SQL (команда сама печатает rollback в конце). + +Пример вывода на боевом маршруте: + +``` +Path 1979: 68 -> 13 waypoints (min_yd=50.0, removed 55) +Backup table: `custom_taxi_path_nodes_backup_1979_1778283612` +To rollback: + DELETE FROM custom_taxi_path_nodes WHERE path_id = 1979; + INSERT INTO custom_taxi_path_nodes + SELECT * FROM `custom_taxi_path_nodes_backup_1979_1778283612`; +``` + +Что делать после `thin`: + +``` +.taxi path validate 1979 # должно быть 0 errors, 0 warnings про <40 yd +.taxi path report 1979 # Min/Max segment в коридоре 40-120 yd +``` + +Затем `bash tools/moonwell-taxi/redeploy.sh` — экспорт DBC, заливка +в `var/client-data-live/dbc/`, рестарт worldserver-а. + +**Важно:** `thin` работает только с записями в `custom_taxi_path_nodes`. +DBC сами по себе не меняются — для актуализации сервера нужен +`redeploy.sh` (или ручной `export-dbc` + `cp` + `restart`). Без этого +in-game проверка увидит старый, плотный маршрут. + +--- + +## Структура БД + +Все три таблицы в `acore_world`. См. +[data/sql/updates/pending_db_world/rev_*.sql](../../data/sql/updates/pending_db_world/). + +``` +custom_taxi_nodes + id int unsigned PRIMARY KEY (модуль выдаёт >= 441) + name varchar(100) (utf8mb4, утаивается во все 16 локалей DBC) + map_id smallint unsigned + position_x float + position_y float + position_z float + mount_alliance int unsigned + mount_horde int unsigned + created_by bigint unsigned (GUID GM-а на момент создания) + created_at timestamp + updated_at timestamp + +custom_taxi_paths + id int unsigned PRIMARY KEY (модуль выдаёт >= 1979) + from_taxi_node_id int unsigned (custom ИЛИ vanilla) + to_taxi_node_id int unsigned (custom ИЛИ vanilla) + cost int unsigned (медяки) + is_enabled tinyint(1) + created_by, created_at, updated_at + KEY idx_from_to (from_taxi_node_id, to_taxi_node_id) + +custom_taxi_path_nodes + id int unsigned AUTO_INCREMENT (внутренний; DBC ID — на экспорте) + path_id int unsigned + node_index int unsigned (UNIQUE с path_id, 0-based) + map_id smallint unsigned + position_x, _y, _z float + flags int unsigned (TaxiPathNode flags: teleport=1, stop=2 ...) + delay int unsigned + arrival_event_id int unsigned + departure_event_id int unsigned + comment varchar(255) + created_at timestamp +``` + +ID-диапазоны: модуль предпочитает `441..448` для новых нод, а когда +этот диапазон занят, использует свободные дырки внутри `1..440`. +Для путей модуль начинает с `1979`, сразу после vanilla max `1978`. +Схема БД эти правила не enforce-ит, поэтому в теории можно вставить ID +руками, но дальше CLI откажется писать DBC при коллизии с vanilla. + +`from`/`to` в `custom_taxi_paths` намеренно без FK: маршрут регулярно +ведёт от custom-нода к vanilla (как в исходном кейсе ТЗ — +«Златоземье → Штормград»). + +--- + +## Связь с CLI moonwell-taxi + +Модуль *только* пишет в БД. DBC генерирует внешний инструмент +[tools/moonwell-taxi/](../../tools/moonwell-taxi/): + +```bash +# Прочесть custom_taxi_*, смерджить с vanilla DBC из корня репозитория, +# записать оба комплекта (server + client) и проверить sha256. +python3 tools/moonwell-taxi/moonwell_taxi.py export-dbc + +# Output: +# build/taxi/export/server/dbc/TaxiNodes.dbc +# build/taxi/export/server/dbc/TaxiPath.dbc +# build/taxi/export/server/dbc/TaxiPathNode.dbc +# build/taxi/export/client/DBFilesClient/TaxiNodes.dbc +# build/taxi/export/client/DBFilesClient/TaxiPath.dbc +# build/taxi/export/client/DBFilesClient/TaxiPathNode.dbc +# + sha256.txt в каждом каталоге +``` + +Если sha256 серверной и клиентской копий не совпадают — CLI выходит с +`exit code 1`, не считая экспорт валидным. + +**Важно:** модуль и CLI общаются *только через схему БД*. Никаких +общих хедеров, FIFO или RPC. Меняется схема — обновляется и CLI. + +--- + +## Известные ограничения + +- **Только custom_taxi_*, не модифицирует vanilla.** Чтобы поправить + существующую такситочку или путь vanilla, нужны другие средства. +- **Имя пишется в locale slot текущего vanilla DBC.** Exporter + авто-инферит slot и `Name_lang_mask` из `TaxiNodes.dbc` (для ruRU это + slot `8`). Per-locale override таблицы пока нет. +- **Mount creature ID — авто-подбор по большинству на карте.** Для + Outland/Northrend и нестандартных карт fallback `541/541` может + выглядеть как Альянсный грифон у Орды. Меняется UPDATE-ом + `custom_taxi_nodes.mount_horde`. +- **`.taxi markers spawn` спавнит только на текущей карте.** Точки на + других континентах пропускаются — таково ограничение grid-load. +- **`.taxi path test` не запускает реальный flight.** Реальный полёт + работает только через DBC; у `path test` MVP-уровень — телепорт на + первую точку. Делать `Player::ActivateTaxiPathTo` для путей не из + DBC требует более глубокой интеграции (out of scope MVP). +- **Сессия записи не персистится.** Рестарт worldserver сбрасывает + состояние `.taxi record`. Workaround: `.taxi record start ` + заново — точки в БД остаются. +- **Конвенция Alliance/Horde в DBC обратна тексту ТЗ.** В реальном + DBC `MountCreatureID[0]` — Horde, `[1]` — Alliance + (см. `ObjectMgr.cpp:7229`). Колонки `mount_alliance` / + `mount_horde` в БД названы корректно — конверсия делается при записи + в DBC. + +--- + +## FAQ / типичные ошибки + +### «Path 1979 has no waypoints — record some first» + +Создан путь, но не записано ни одной точки. Запусти +`.taxi record start 1979`, пройди маршрут, нажимая +`.taxi record point` в нужных местах, заверши `.taxi record finish`. + +### «custom_taxi_paths id=N not found» + +Путь не существует или `is_enabled=0`. Список — `.taxi path list`. + +### «from-node N does not exist (custom or vanilla)» + +ID не найден ни в `custom_taxi_nodes`, ни в vanilla `TaxiNodes.dbc`. +Список — `.taxi node list `. + +### «An enabled path X -> Y already exists with id Z» + +Активный маршрут `from→to` уже существует. Либо +`UPDATE custom_taxi_paths SET is_enabled=0 WHERE id=Z;`, либо +используй существующий маршрут. + +### `.taxi path validate` показывает много `< 40 yd` warnings + +Сегменты слишком короткие — `IsNodeIncludedInShortenedPath` их режет +и реальный полёт пройдёт «через» эти точки. Удали лишние через +`.taxi record undo` или прямым `DELETE` по `node_index`. + +### Marker GO entry 178559 is missing from gameobject_template + +На сервере зачищен banner GO. Поменяй `MARKER_GO_ENTRY` в +[src/cs_taxi_builder.cpp](src/cs_taxi_builder.cpp) на любой видимый +GO из `gameobject_template` и пересобери модуль. + +### После `export-dbc` сервер видит точку, а клиент — нет + +Клиент читает свои `DBFilesClient/*.dbc` из MPQ-патчей. Проверь, что +файлы из `build/taxi/export/client/DBFilesClient/` попали в +подключённый патч (например, `patch-X.MPQ` с приоритетом выше +базовых) и удалён клиентский `Cache/`. + +### `Server/client DBC match: FAIL` после экспорта + +Не должно случаться при штатной работе CLI (server и client пишутся +из одного буфера). Если всё-таки — баг в инструменте, открывайте +issue с `sha256.txt` обоих комплектов. diff --git a/modules/mod-taxi-builder/conf/conf.sh.dist b/modules/mod-taxi-builder/conf/conf.sh.dist new file mode 100644 index 0000000..f1f641a --- /dev/null +++ b/modules/mod-taxi-builder/conf/conf.sh.dist @@ -0,0 +1 @@ +#!/usr/bin/env bash diff --git a/modules/mod-taxi-builder/include.sh b/modules/mod-taxi-builder/include.sh new file mode 100644 index 0000000..8272974 --- /dev/null +++ b/modules/mod-taxi-builder/include.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +MOD_TAXI_BUILDER_ROOT="$( cd "$( dirname "${BASH_SOURCE[0]}" )/" && pwd )" + +source $MOD_TAXI_BUILDER_ROOT"/conf/conf.sh.dist" + +if [ -f $MOD_TAXI_BUILDER_ROOT"/conf/conf.sh" ]; then + source $MOD_TAXI_BUILDER_ROOT"/conf/conf.sh" +fi diff --git a/modules/mod-taxi-builder/src/cs_taxi_builder.cpp b/modules/mod-taxi-builder/src/cs_taxi_builder.cpp new file mode 100644 index 0000000..d494d9a --- /dev/null +++ b/modules/mod-taxi-builder/src/cs_taxi_builder.cpp @@ -0,0 +1,1395 @@ +/* + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * mod-taxi-builder: in-game GM commands for authoring custom taxi nodes + * and flight paths. The custom_taxi_* tables in acore_world are the + * source of truth; the moonwell-taxi Python CLI generates DBCs from them. + */ + +#include "Chat.h" +#include "CommandScript.h" +#include "DBCStores.h" +#include "DatabaseEnv.h" +#include "GameObject.h" +#include "ObjectAccessor.h" +#include "ObjectMgr.h" +#include "Player.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using namespace Acore::ChatCommands; + +namespace +{ + // TaxiNode ids must stay <= 448. AzerothCore tracks per-node discovery + // in a TaxiMask of size 14 uint32 = 448 bits (DBCStructure.h + // `TaxiMaskSize = 14`). GetNearestTaxiNode skips any node whose + // `(id - 1) / 32 >= TaxiMaskSize`, so nodes with id > 448 are silently + // invisible to the flight-master logic — flights from those nodes loop + // and drop the player at a graveyard. + // + // To squeeze more than 8 custom nodes out of this cap we also reuse + // gaps inside the vanilla range (1..PREFERRED_MIN-1). Allocation prefers + // the above-vanilla band first (PREFERRED_MIN..ABS_MAX) so vanilla gaps + // stay reserved for spillover or for explicit GM picks; once that band + // is full we walk vanilla gaps from low to high. + constexpr uint32 CUSTOM_TAXI_NODE_ID_PREFERRED_MIN = 441; // first id past stock vanilla (~440 here) + constexpr uint32 CUSTOM_TAXI_NODE_ID_ABS_MAX = 448; // hard ceiling from TaxiMaskSize + // TaxiPath ids are not limited by TaxiMask, but the 3.3.5a client can + // allocate client-side path tables up to max id. Keep custom paths dense, + // immediately after the stock 3.3.5a max id (1978 in this data set). + constexpr uint32 CUSTOM_TAXI_PATH_ID_MIN = 1979; + + // Backwards-compatible aliases used elsewhere in this file. + constexpr uint32 CUSTOM_TAXI_NODE_ID_MIN = CUSTOM_TAXI_NODE_ID_PREFERRED_MIN; + constexpr uint32 CUSTOM_TAXI_NODE_ID_MAX = CUSTOM_TAXI_NODE_ID_ABS_MAX; + + // Loads every id in `custom_taxi_nodes` (capped at the absolute max so + // we don't drag in legacy out-of-range rows). The set is used as a + // companion to `sTaxiNodesStore` when scanning for free slots — pending + // custom nodes that haven't been merged into a DBC yet still live only + // in the table. + std::set LoadCustomTaxiNodeIds() + { + std::set ids; + QueryResult r = WorldDatabase.Query( + "SELECT id FROM custom_taxi_nodes WHERE id <= {}", CUSTOM_TAXI_NODE_ID_ABS_MAX); + if (r) + do { ids.insert(r->Fetch()[0].Get()); } while (r->NextRow()); + return ids; + } + + bool IsTaxiNodeIdInUse(uint32 id, std::set const& customIds) + { + return sTaxiNodesStore.LookupEntry(id) != nullptr || customIds.count(id) != 0; + } + + // Returns the next free TaxiNode id within [1, ABS_MAX], 0 if exhausted. + // Preference: PREFERRED_MIN..ABS_MAX first (above-vanilla, always safe), + // then vanilla gaps 1..PREFERRED_MIN-1. + uint32 PickNextCustomTaxiNodeId() + { + std::set const customIds = LoadCustomTaxiNodeIds(); + + for (uint32 id = CUSTOM_TAXI_NODE_ID_PREFERRED_MIN; id <= CUSTOM_TAXI_NODE_ID_ABS_MAX; ++id) + if (!IsTaxiNodeIdInUse(id, customIds)) + return id; + + for (uint32 id = 1; id < CUSTOM_TAXI_NODE_ID_PREFERRED_MIN; ++id) + if (!IsTaxiNodeIdInUse(id, customIds)) + return id; + + return 0; + } + + uint32 PickNextCustomId(char const* table, uint32 floor) + { + QueryResult result = WorldDatabase.Query( + "SELECT MAX(id) FROM {} WHERE id >= {}", table, floor); + + if (!result) + return floor; + + Field* fields = result->Fetch(); + if (fields[0].IsNull()) + return floor; + + return fields[0].Get() + 1; + } + + uint32 PickNextCustomTaxiPathId() { return PickNextCustomId("custom_taxi_paths", CUSTOM_TAXI_PATH_ID_MIN); } + + // Per-GM in-memory recording state. Cleared on worldserver restart; the + // GM just reissues `.taxi record start ` to resume — already + // recorded waypoints sit in custom_taxi_path_nodes, not here. + struct RecordingSession + { + uint32 pathId; + uint32 baselinePointCount; // waypoints that existed at session start + }; + std::unordered_map g_recording; + + // Banner Stand of Argent Crusade — a small, universally-loaded visible + // GO. Override this if your world DB strips it. + constexpr uint32 MARKER_GO_ENTRY = 178559; + constexpr uint32 MARKER_RESPAWN_SECONDS = 24 * 60 * 60; + + // Active marker GOs per path, addressed by path id (not per-GM, since + // markers are world-state and shared across all GMs looking at the path). + std::unordered_map> g_markers; + + void DespawnMarkersForPath(Player* anchor, uint32 pathId) + { + auto it = g_markers.find(pathId); + if (it == g_markers.end()) + return; + for (ObjectGuid const& guid : it->second) + { + if (GameObject* go = ObjectAccessor::GetGameObject(*anchor, guid)) + go->Delete(); + } + g_markers.erase(it); + } + + uint32 CountPathPoints(uint32 pathId) + { + QueryResult r = WorldDatabase.Query( + "SELECT COUNT(*) FROM custom_taxi_path_nodes WHERE path_id = {}", pathId); + return r ? r->Fetch()[0].Get() : 0; + } + + std::optional NextNodeIndex(uint32 pathId) + { + QueryResult r = WorldDatabase.Query( + "SELECT MAX(node_index) FROM custom_taxi_path_nodes WHERE path_id = {}", pathId); + if (!r) + return 0; + Field* f = r->Fetch(); + if (f[0].IsNull()) + return 0; + return f[0].Get() + 1; + } + + struct ResolvedTaxiNode + { + uint32 id; + uint32 mapId; + float x; + float y; + float z; + std::string name; + bool isCustom; + }; + + std::optional ResolveTaxiNode(uint32 nodeId, LocaleConstant locale) + { + if (QueryResult result = WorldDatabase.Query( + "SELECT name, map_id, position_x, position_y, position_z " + "FROM custom_taxi_nodes WHERE id = {}", nodeId)) + { + Field* f = result->Fetch(); + return ResolvedTaxiNode{ + nodeId, f[1].Get(), + f[2].Get(), f[3].Get(), f[4].Get(), + f[0].Get(), true + }; + } + + if (TaxiNodesEntry const* node = sTaxiNodesStore.LookupEntry(nodeId)) + { + std::string name = node->name[locale] ? node->name[locale] : (node->name[0] ? node->name[0] : ""); + return ResolvedTaxiNode{ + nodeId, node->map_id, node->x, node->y, node->z, std::move(name), false + }; + } + + return std::nullopt; + } + + // Thresholds per spec section 4. + constexpr uint32 MIN_POINTS_HARD = 3; // 4.2 floor + constexpr uint32 MIN_POINTS_RECOMMENDED = 12; // 4.2 short route guideline + constexpr float SEG_TOO_SHORT_YD = 40.f; // 4.3 SKIP_SPLINE_POINT_DISTANCE + constexpr float SEG_LONG_WARN_YD = 120.f; // 4.3 long-segment warning + constexpr float SEG_LONG_ERROR_YD = 200.f; // 4.3 hard error + constexpr float ENDPOINT_NEAR_YD = 20.f; // 4.4 first/last proximity + constexpr float TURN_WARN_DEG = 75.f; // 4.6 sharp-turn threshold + + struct PathWaypoint + { + uint32 nodeIndex; + uint32 mapId; + float x, y, z; + }; + + struct ValidationReport + { + std::vector errors; + std::vector warnings; + bool ok() const { return errors.empty(); } + }; + + float DistXY(PathWaypoint const& a, PathWaypoint const& b) + { + float dx = b.x - a.x, dy = b.y - a.y; + return std::sqrt(dx * dx + dy * dy); + } + + // Returns 0..180 angle (degrees) between segments (i-1 -> i) and (i -> i+1) + // measured in the XY plane. 0 = straight, 90 = right angle, 180 = U-turn. + float TurnAngleDegXY(PathWaypoint const& a, PathWaypoint const& b, PathWaypoint const& c) + { + float v1x = b.x - a.x, v1y = b.y - a.y; + float v2x = c.x - b.x, v2y = c.y - b.y; + float n1 = std::sqrt(v1x*v1x + v1y*v1y); + float n2 = std::sqrt(v2x*v2x + v2y*v2y); + if (n1 < 0.0001f || n2 < 0.0001f) + return 0.f; + float cosA = (v1x*v2x + v1y*v2y) / (n1 * n2); + cosA = std::clamp(cosA, -1.f, 1.f); + return std::acos(cosA) * 180.f / float(M_PI); + } + + std::vector LoadPathWaypoints(uint32 pathId) + { + std::vector out; + QueryResult r = WorldDatabase.Query( + "SELECT node_index, map_id, position_x, position_y, position_z " + "FROM custom_taxi_path_nodes WHERE path_id = {} ORDER BY node_index", + pathId); + if (!r) + return out; + do + { + Field* f = r->Fetch(); + out.push_back({ f[0].Get(), f[1].Get(), + f[2].Get(), f[3].Get(), f[4].Get() }); + } while (r->NextRow()); + return out; + } + + // Apply spec sections 4.1 - 4.6. The caller is responsible for pretty-printing. + ValidationReport ValidatePath(uint32 pathId, LocaleConstant locale) + { + ValidationReport rep; + char buf[256]; + + QueryResult pathRow = WorldDatabase.Query( + "SELECT from_taxi_node_id, to_taxi_node_id FROM custom_taxi_paths WHERE id = {}", + pathId); + if (!pathRow) + { + rep.errors.emplace_back("path not found in custom_taxi_paths"); + return rep; + } + uint32 const fromId = pathRow->Fetch()[0].Get(); + uint32 const toId = pathRow->Fetch()[1].Get(); + + // 4.1: from/to nodes must resolve. + auto fromNode = ResolveTaxiNode(fromId, locale); + auto toNode = ResolveTaxiNode(toId, locale); + if (!fromNode) + { + std::snprintf(buf, sizeof(buf), "from-node %u does not exist (custom or vanilla)", fromId); + rep.errors.emplace_back(buf); + } + if (!toNode) + { + std::snprintf(buf, sizeof(buf), "to-node %u does not exist (custom or vanilla)", toId); + rep.errors.emplace_back(buf); + } + + auto wps = LoadPathWaypoints(pathId); + + // 4.1: must have waypoints; node_index must be 0..N-1 contiguous. + if (wps.empty()) + { + rep.errors.emplace_back("path has no waypoints (custom_taxi_path_nodes is empty for this path)"); + return rep; + } + for (size_t i = 0; i < wps.size(); ++i) + { + if (wps[i].nodeIndex != i) + { + std::snprintf(buf, sizeof(buf), + "node_index is not contiguous from 0: position %zu has node_index=%u", + i, wps[i].nodeIndex); + rep.errors.emplace_back(buf); + break; + } + } + + // 4.2: point count. + if (wps.size() < MIN_POINTS_HARD) + { + std::snprintf(buf, sizeof(buf), + "only %u waypoints; the spec requires at least %u", + uint32(wps.size()), MIN_POINTS_HARD); + rep.errors.emplace_back(buf); + } + else if (wps.size() < MIN_POINTS_RECOMMENDED) + { + std::snprintf(buf, sizeof(buf), + "only %u waypoints; %u+ recommended for a short route", + uint32(wps.size()), MIN_POINTS_RECOMMENDED); + rep.warnings.emplace_back(buf); + } + + // 4.3: segment XY distance bounds. + for (size_t i = 1; i < wps.size(); ++i) + { + if (wps[i].mapId != wps[i - 1].mapId) + { + std::snprintf(buf, sizeof(buf), + "segment %zu->%zu crosses map (%u -> %u) — taxi paths cannot change continent mid-route", + i - 1, i, wps[i - 1].mapId, wps[i].mapId); + rep.errors.emplace_back(buf); + continue; + } + float d = DistXY(wps[i - 1], wps[i]); + if (d > SEG_LONG_ERROR_YD) + { + std::snprintf(buf, sizeof(buf), + "segment %zu->%zu is %.1f yd (> %.0f yd hard limit)", + i - 1, i, d, SEG_LONG_ERROR_YD); + rep.errors.emplace_back(buf); + } + else if (d > SEG_LONG_WARN_YD) + { + std::snprintf(buf, sizeof(buf), + "segment %zu->%zu is %.1f yd (> %.0f yd, may give a jagged spline)", + i - 1, i, d, SEG_LONG_WARN_YD); + rep.warnings.emplace_back(buf); + } + else if (d < SEG_TOO_SHORT_YD) + { + std::snprintf(buf, sizeof(buf), + "segment %zu->%zu is %.1f yd (< %.0f yd, may be filtered by IsNodeIncludedInShortenedPath)", + i - 1, i, d, SEG_TOO_SHORT_YD); + rep.warnings.emplace_back(buf); + } + } + + // 4.4: first/last proximity to taxi nodes. + if (fromNode) + { + if (wps.front().mapId != fromNode->mapId) + { + std::snprintf(buf, sizeof(buf), + "first waypoint is on map %u but FromTaxiNode is on map %u", + wps.front().mapId, fromNode->mapId); + rep.errors.emplace_back(buf); + } + else + { + PathWaypoint anchor{ 0, fromNode->mapId, fromNode->x, fromNode->y, fromNode->z }; + float d = DistXY(wps.front(), anchor); + if (d > ENDPOINT_NEAR_YD) + { + std::snprintf(buf, sizeof(buf), + "first waypoint is %.1f yd from FromTaxiNode (> %.0f yd)", + d, ENDPOINT_NEAR_YD); + rep.warnings.emplace_back(buf); + } + } + } + if (toNode) + { + if (wps.back().mapId != toNode->mapId) + { + std::snprintf(buf, sizeof(buf), + "last waypoint is on map %u but ToTaxiNode is on map %u", + wps.back().mapId, toNode->mapId); + rep.errors.emplace_back(buf); + } + else + { + PathWaypoint anchor{ 0, toNode->mapId, toNode->x, toNode->y, toNode->z }; + float dXY = DistXY(wps.back(), anchor); + if (dXY > ENDPOINT_NEAR_YD) + { + std::snprintf(buf, sizeof(buf), + "last waypoint is %.1f yd from ToTaxiNode (> %.0f yd) — players may land off-target", + dXY, ENDPOINT_NEAR_YD); + rep.warnings.emplace_back(buf); + } + // 4.5: rough Z sanity. Server geometry isn't always available + // for arbitrary maps without GRID load, so just compare against + // the ToTaxiNode Z; large deltas almost always mean a fall. + float dZ = std::abs(wps.back().z - toNode->z); + if (dZ > 30.f) + { + std::snprintf(buf, sizeof(buf), + "last waypoint Z=%.1f vs ToTaxiNode Z=%.1f (|dZ|=%.1f) — expect a drop on landing", + wps.back().z, toNode->z, dZ); + rep.warnings.emplace_back(buf); + } + } + } + + // 4.6: sharp turns in XY. + for (size_t i = 1; i + 1 < wps.size(); ++i) + { + if (wps[i - 1].mapId != wps[i].mapId || wps[i].mapId != wps[i + 1].mapId) + continue; + float ang = TurnAngleDegXY(wps[i - 1], wps[i], wps[i + 1]); + if (ang > TURN_WARN_DEG) + { + std::snprintf(buf, sizeof(buf), + "sharp turn at NodeIndex %u: %.0f deg (> %.0f). Consider adding intermediate points.", + wps[i].nodeIndex, ang, TURN_WARN_DEG); + rep.warnings.emplace_back(buf); + } + } + + return rep; + } + + // Picks the most common Alliance/Horde mount creatures used by vanilla + // taxi nodes on the same continent as the new one. Falls back to the + // generic creature 541 (used by the spec example) when the map has no + // vanilla nodes to learn from. + // + // DBC convention: MountCreatureID[0] is the Horde mount, [1] is the + // Alliance mount (see ObjectMgr.cpp:7229 `teamId == TEAM_ALLIANCE ? 1 : 0`). + // The taxi-builder spec text has the indices inverted; trust the runtime. + std::pair PickDefaultMountsForMap(uint32 mapId) + { + std::map allianceCount; + std::map hordeCount; + + for (auto const& node : sTaxiNodesStore) + { + if (!node || node->map_id != mapId) + continue; + if (node->MountCreatureID[0]) + ++hordeCount[node->MountCreatureID[0]]; + if (node->MountCreatureID[1]) + ++allianceCount[node->MountCreatureID[1]]; + } + + auto pickMost = [](std::map const& counts) -> uint32 + { + uint32 best = 0; + uint32 bestCount = 0; + for (auto const& [creature, count] : counts) + { + if (count > bestCount) + { + best = creature; + bestCount = count; + } + } + return best; + }; + + uint32 alliance = pickMost(allianceCount); + uint32 horde = pickMost(hordeCount); + if (!alliance) alliance = 541; + if (!horde) horde = 541; + return { alliance, horde }; + } +} + +class taxi_builder_commandscript : public CommandScript +{ +public: + taxi_builder_commandscript() : CommandScript("taxi_builder_commandscript") { } + + ChatCommandTable GetCommands() const override + { + static ChatCommandTable taxiNodeCommandTable = + { + { "create", HandleTaxiNodeCreate, SEC_ADMINISTRATOR, Console::No }, + { "list", HandleTaxiNodeList, SEC_ADMINISTRATOR, Console::Yes }, + { "free", HandleTaxiNodeFree, SEC_ADMINISTRATOR, Console::Yes }, + { "goto", HandleTaxiNodeGoto, SEC_ADMINISTRATOR, Console::No }, + }; + + static ChatCommandTable taxiPathCommandTable = + { + { "create", HandleTaxiPathCreate, SEC_ADMINISTRATOR, Console::Yes }, + { "reverse", HandleTaxiPathReverse, SEC_ADMINISTRATOR, Console::Yes }, + { "list", HandleTaxiPathList, SEC_ADMINISTRATOR, Console::Yes }, + { "report", HandleTaxiPathReport, SEC_ADMINISTRATOR, Console::Yes }, + { "validate", HandleTaxiPathValidate, SEC_ADMINISTRATOR, Console::Yes }, + { "goto", HandleTaxiPathGoto, SEC_ADMINISTRATOR, Console::No }, + { "test", HandleTaxiPathTest, SEC_ADMINISTRATOR, Console::No }, + }; + + static ChatCommandTable taxiRecordCommandTable = + { + { "start", HandleTaxiRecordStart, SEC_ADMINISTRATOR, Console::No }, + { "point", HandleTaxiRecordPoint, SEC_ADMINISTRATOR, Console::No }, + { "undo", HandleTaxiRecordUndo, SEC_ADMINISTRATOR, Console::No }, + { "finish", HandleTaxiRecordFinish, SEC_ADMINISTRATOR, Console::No }, + { "cancel", HandleTaxiRecordCancel, SEC_ADMINISTRATOR, Console::No }, + }; + + static ChatCommandTable taxiMarkersCommandTable = + { + { "spawn", HandleTaxiMarkersSpawn, SEC_ADMINISTRATOR, Console::No }, + { "clear", HandleTaxiMarkersClear, SEC_ADMINISTRATOR, Console::Yes }, + }; + + static ChatCommandTable taxiExportCommandTable = + { + { "dbc", HandleTaxiExportDbc, SEC_ADMINISTRATOR, Console::Yes }, + }; + + static ChatCommandTable taxiCommandTable = + { + { "node", taxiNodeCommandTable }, + { "path", taxiPathCommandTable }, + { "record", taxiRecordCommandTable }, + { "markers", taxiMarkersCommandTable }, + { "export", taxiExportCommandTable }, + }; + + static ChatCommandTable commandTable = + { + { "taxi", taxiCommandTable }, + }; + + return commandTable; + } + + static bool HandleTaxiNodeCreate(ChatHandler* handler, QuotedString name) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player position."); + return false; + } + + if (name.empty()) + { + handler->SendErrorMessage("Node name must not be empty."); + return false; + } + + uint32 const mapId = player->GetMapId(); + float const x = player->GetPositionX(); + float const y = player->GetPositionY(); + float const z = player->GetPositionZ(); + auto const [mountAlliance, mountHorde] = PickDefaultMountsForMap(mapId); + uint32 const newId = PickNextCustomTaxiNodeId(); + if (!newId) + { + handler->SendErrorMessage( + "All TaxiNode ids in 1..{} are occupied (vanilla + existing custom). " + "AzerothCore TaxiMaskSize=14 caps node ids at 448. " + "Free a slot by deleting an unused custom_taxi_nodes row " + "(also remove its dependents from custom_taxi_paths). " + "Run `.taxi node free` to see currently available ids.", + CUSTOM_TAXI_NODE_ID_ABS_MAX); + return false; + } + uint64 const creatorGuid = player->GetGUID().GetCounter(); + + std::string escaped = name; + WorldDatabase.EscapeString(escaped); + + WorldDatabase.Execute( + "INSERT INTO custom_taxi_nodes " + "(id, name, map_id, position_x, position_y, position_z, " + " mount_alliance, mount_horde, created_by) " + "VALUES ({}, '{}', {}, {}, {}, {}, {}, {}, {})", + newId, escaped, mapId, x, y, z, mountAlliance, mountHorde, creatorGuid); + + handler->PSendSysMessage( + "[taxi-builder] node created: id={} name=\"{}\" map={} xyz=({:.2f}, {:.2f}, {:.2f}) " + "mounts=A:{}/H:{}", + newId, name.c_str(), mapId, x, y, z, mountAlliance, mountHorde); + return true; + } + + static bool HandleTaxiNodeList(ChatHandler* handler, Optional filter) + { + std::string needle; + if (filter) + { + needle = *filter; + std::transform(needle.begin(), needle.end(), needle.begin(), + [](unsigned char c) { return std::tolower(c); }); + } + + auto matches = [&needle](std::string const& name) -> bool + { + if (needle.empty()) + return true; + std::string lower = name; + std::transform(lower.begin(), lower.end(), lower.begin(), + [](unsigned char c) { return std::tolower(c); }); + return lower.find(needle) != std::string::npos; + }; + + constexpr uint32 MAX_LINES = 50; + uint32 shown = 0; + uint32 totalCustom = 0; + uint32 totalVanilla = 0; + + // Custom nodes first. + if (QueryResult result = WorldDatabase.Query( + "SELECT id, name, map_id, position_x, position_y, position_z " + "FROM custom_taxi_nodes ORDER BY id")) + { + do + { + Field* fields = result->Fetch(); + uint32 id = fields[0].Get(); + std::string name = fields[1].Get(); + uint32 mapId = fields[2].Get(); + float x = fields[3].Get(); + float y = fields[4].Get(); + float z = fields[5].Get(); + + ++totalCustom; + if (!matches(name)) + continue; + if (shown >= MAX_LINES) + continue; + + handler->PSendSysMessage( + "[custom] {} {} @ map {} ({:.1f}, {:.1f}, {:.1f})", + id, name.c_str(), mapId, x, y, z); + ++shown; + } while (result->NextRow()); + } + + // Vanilla nodes from the loaded DBC. + int const locale = handler->GetSessionDbcLocale(); + for (auto const& node : sTaxiNodesStore) + { + if (!node) + continue; + + std::string name = node->name[locale] ? node->name[locale] : ""; + if (name.empty() && node->name[0]) + name = node->name[0]; + + ++totalVanilla; + if (!matches(name)) + continue; + if (shown >= MAX_LINES) + continue; + + handler->PSendSysMessage( + "[vanilla] {} {} @ map {} ({:.1f}, {:.1f}, {:.1f})", + node->ID, name.c_str(), node->map_id, node->x, node->y, node->z); + ++shown; + } + + handler->PSendSysMessage( + "Shown {} (cap {}). Custom: {}, Vanilla: {}.{}", + shown, MAX_LINES, totalCustom, totalVanilla, + shown >= MAX_LINES ? " Use a filter to narrow." : ""); + return true; + } + + static bool HandleTaxiNodeFree(ChatHandler* handler) + { + std::set const customIds = LoadCustomTaxiNodeIds(); + std::vector free; + free.reserve(96); + for (uint32 id = 1; id <= CUSTOM_TAXI_NODE_ID_ABS_MAX; ++id) + if (!IsTaxiNodeIdInUse(id, customIds)) + free.push_back(id); + + uint32 const above = uint32(std::count_if( + free.begin(), free.end(), + [](uint32 id) { return id >= CUSTOM_TAXI_NODE_ID_PREFERRED_MIN; })); + uint32 const inGaps = uint32(free.size()) - above; + + handler->PSendSysMessage( + "Free TaxiNode ids in 1..{}: {} total ({} above-vanilla {}..{}, {} in vanilla gaps).", + CUSTOM_TAXI_NODE_ID_ABS_MAX, uint32(free.size()), + above, CUSTOM_TAXI_NODE_ID_PREFERRED_MIN, CUSTOM_TAXI_NODE_ID_ABS_MAX, + inGaps); + + if (free.empty()) + { + handler->PSendSysMessage("No free ids — TaxiMask range exhausted."); + return true; + } + + // Coalesce into ranges so 84 ids don't spam the chat as 84 lines. + size_t i = 0; + while (i < free.size()) + { + uint32 const start = free[i]; + while (i + 1 < free.size() && free[i + 1] == free[i] + 1) + ++i; + uint32 const end = free[i]; + if (start == end) + handler->PSendSysMessage(" {}", start); + else + handler->PSendSysMessage(" {}..{} ({})", start, end, end - start + 1); + ++i; + } + return true; + } + + static bool HandleTaxiNodeGoto(ChatHandler* handler, uint32 nodeId) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player."); + return false; + } + + auto resolved = ResolveTaxiNode(nodeId, handler->GetSessionDbcLocale()); + if (!resolved) + { + handler->SendErrorMessage("Taxi node {} not found in custom_taxi_nodes or TaxiNodes.dbc.", nodeId); + return false; + } + + player->SaveRecallPosition(); + player->TeleportTo(resolved->mapId, resolved->x, resolved->y, resolved->z, player->GetOrientation()); + handler->PSendSysMessage( + "[taxi-builder] teleported to {} node {} ({})", + resolved->isCustom ? "custom" : "vanilla", nodeId, resolved->name.c_str()); + return true; + } + + static bool HandleTaxiPathCreate(ChatHandler* handler, uint32 fromNodeId, uint32 toNodeId, uint32 cost) + { + if (fromNodeId == toNodeId) + { + handler->SendErrorMessage("from and to nodes must differ."); + return false; + } + + LocaleConstant const locale = handler->GetSessionDbcLocale(); + auto fromNode = ResolveTaxiNode(fromNodeId, locale); + if (!fromNode) + { + handler->SendErrorMessage("from-node {} does not exist (custom or vanilla).", fromNodeId); + return false; + } + auto toNode = ResolveTaxiNode(toNodeId, locale); + if (!toNode) + { + handler->SendErrorMessage("to-node {} does not exist (custom or vanilla).", toNodeId); + return false; + } + + if (QueryResult dup = WorldDatabase.Query( + "SELECT id FROM custom_taxi_paths " + "WHERE from_taxi_node_id = {} AND to_taxi_node_id = {} AND is_enabled = 1 LIMIT 1", + fromNodeId, toNodeId)) + { + uint32 existing = dup->Fetch()[0].Get(); + handler->SendErrorMessage( + "An enabled path {} -> {} already exists with id {}. " + "Disable it first or use a different direction.", + fromNodeId, toNodeId, existing); + return false; + } + + uint32 const newId = PickNextCustomTaxiPathId(); + uint64 const creatorGuid = handler->GetSession() && handler->GetSession()->GetPlayer() + ? handler->GetSession()->GetPlayer()->GetGUID().GetCounter() : 0; + + WorldDatabase.Execute( + "INSERT INTO custom_taxi_paths " + "(id, from_taxi_node_id, to_taxi_node_id, cost, is_enabled, created_by) " + "VALUES ({}, {}, {}, {}, 1, {})", + newId, fromNodeId, toNodeId, cost, creatorGuid); + + handler->PSendSysMessage( + "[taxi-builder] path created: id={} {} ({}) -> {} ({}) cost={} copper", + newId, + fromNodeId, fromNode->name.c_str(), + toNodeId, toNode->name.c_str(), + cost); + handler->PSendSysMessage( + "Now record waypoints with: .taxi record start {} -> .taxi record point ... -> .taxi record finish", + newId); + return true; + } + + static bool HandleTaxiPathReverse(ChatHandler* handler, uint32 pathId) + { + QueryResult origPath = WorldDatabase.Query( + "SELECT from_taxi_node_id, to_taxi_node_id, cost FROM custom_taxi_paths WHERE id = {}", + pathId); + if (!origPath) + { + handler->SendErrorMessage("custom_taxi_paths id={} not found.", pathId); + return false; + } + Field* pf = origPath->Fetch(); + uint32 const origFrom = pf[0].Get(); + uint32 const origTo = pf[1].Get(); + uint32 const cost = pf[2].Get(); + + // Read waypoints in original order so we can reverse them. + QueryResult points = WorldDatabase.Query( + "SELECT node_index, map_id, position_x, position_y, position_z, " + "flags, delay, arrival_event_id, departure_event_id, comment " + "FROM custom_taxi_path_nodes WHERE path_id = {} ORDER BY node_index", + pathId); + if (!points) + { + handler->SendErrorMessage( + "Path {} has no waypoints yet — record some first or there is nothing to reverse.", + pathId); + return false; + } + + if (QueryResult dup = WorldDatabase.Query( + "SELECT id FROM custom_taxi_paths " + "WHERE from_taxi_node_id = {} AND to_taxi_node_id = {} AND is_enabled = 1 LIMIT 1", + origTo, origFrom)) + { + handler->SendErrorMessage( + "An enabled path {} -> {} already exists with id {} — refusing to create a duplicate.", + origTo, origFrom, dup->Fetch()[0].Get()); + return false; + } + + uint32 const newPathId = PickNextCustomTaxiPathId(); + uint64 const creatorGuid = handler->GetSession() && handler->GetSession()->GetPlayer() + ? handler->GetSession()->GetPlayer()->GetGUID().GetCounter() : 0; + + WorldDatabase.Execute( + "INSERT INTO custom_taxi_paths " + "(id, from_taxi_node_id, to_taxi_node_id, cost, is_enabled, created_by) " + "VALUES ({}, {}, {}, {}, 1, {})", + newPathId, origTo, origFrom, cost, creatorGuid); + + struct WP { + uint32 mapId; float x, y, z; + uint32 flags, delay, arrival, departure; + std::string comment; + }; + std::vector waypoints; + do + { + Field* w = points->Fetch(); + waypoints.push_back({ + w[1].Get(), + w[2].Get(), w[3].Get(), w[4].Get(), + w[5].Get(), w[6].Get(), + w[7].Get(), w[8].Get(), + w[9].Get() + }); + } while (points->NextRow()); + + // Re-insert in reverse order, NodeIndex starting at 0. + uint32 newIndex = 0; + for (auto it = waypoints.rbegin(); it != waypoints.rend(); ++it, ++newIndex) + { + std::string esc = it->comment; + WorldDatabase.EscapeString(esc); + WorldDatabase.Execute( + "INSERT INTO custom_taxi_path_nodes " + "(path_id, node_index, map_id, position_x, position_y, position_z, " + " flags, delay, arrival_event_id, departure_event_id, comment) " + "VALUES ({}, {}, {}, {}, {}, {}, {}, {}, {}, {}, '{}')", + newPathId, newIndex, it->mapId, it->x, it->y, it->z, + it->flags, it->delay, it->arrival, it->departure, esc); + } + + handler->PSendSysMessage( + "[taxi-builder] reversed path {} ({}->{}) into new path {} ({}->{}) with {} waypoints.", + pathId, origFrom, origTo, newPathId, origTo, origFrom, uint32(waypoints.size())); + handler->PSendSysMessage( + "WARNING: reverse paths are auto-generated. Check landing waypoints by hand " + "(`.taxi path goto {} `) — a good outbound path is not always a good return.", + newPathId); + return true; + } + + static bool HandleTaxiPathList(ChatHandler* handler) + { + QueryResult result = WorldDatabase.Query( + "SELECT p.id, p.from_taxi_node_id, p.to_taxi_node_id, p.cost, p.is_enabled, " + " (SELECT COUNT(*) FROM custom_taxi_path_nodes n WHERE n.path_id = p.id) AS pts " + "FROM custom_taxi_paths p ORDER BY p.id"); + if (!result) + { + handler->PSendSysMessage("[taxi-builder] no custom paths defined."); + return true; + } + + LocaleConstant const locale = handler->GetSessionDbcLocale(); + uint32 count = 0; + do + { + Field* f = result->Fetch(); + uint32 id = f[0].Get(); + uint32 from = f[1].Get(); + uint32 to = f[2].Get(); + uint32 cost = f[3].Get(); + bool enabled = f[4].Get(); + uint32 pts = f[5].Get(); + + std::string fromName = "?", toName = "?"; + if (auto fr = ResolveTaxiNode(from, locale)) fromName = fr->name; + if (auto tr = ResolveTaxiNode(to, locale)) toName = tr->name; + + handler->PSendSysMessage( + "{} {} ({}) -> {} ({}) cost={} pts={} {}", + id, from, fromName.c_str(), to, toName.c_str(), + cost, pts, enabled ? "enabled" : "DISABLED"); + ++count; + } while (result->NextRow()); + + handler->PSendSysMessage("Total: {} custom paths.", count); + return true; + } + + static bool HandleTaxiPathReport(ChatHandler* handler, uint32 pathId) + { + QueryResult pathRow = WorldDatabase.Query( + "SELECT from_taxi_node_id, to_taxi_node_id, cost, is_enabled " + "FROM custom_taxi_paths WHERE id = {}", pathId); + if (!pathRow) + { + handler->SendErrorMessage("custom_taxi_paths id={} not found.", pathId); + return false; + } + Field* pf = pathRow->Fetch(); + uint32 from = pf[0].Get(); + uint32 to = pf[1].Get(); + uint32 cost = pf[2].Get(); + bool enabled = pf[3].Get(); + + LocaleConstant const locale = handler->GetSessionDbcLocale(); + auto fromNode = ResolveTaxiNode(from, locale); + auto toNode = ResolveTaxiNode(to, locale); + + QueryResult points = WorldDatabase.Query( + "SELECT node_index, map_id, position_x, position_y, position_z " + "FROM custom_taxi_path_nodes WHERE path_id = {} ORDER BY node_index", + pathId); + + struct P { uint32 idx, mapId; float x, y, z; }; + std::vector

wps; + if (points) + { + do + { + Field* w = points->Fetch(); + wps.push_back({ w[0].Get(), w[1].Get(), + w[2].Get(), w[3].Get(), w[4].Get() }); + } while (points->NextRow()); + } + + handler->PSendSysMessage("PathID: {} {}", pathId, enabled ? "(enabled)" : "(DISABLED)"); + handler->PSendSysMessage("From: {} {}{}", from, + fromNode ? fromNode->name.c_str() : "??", + fromNode ? (fromNode->isCustom ? " [custom]" : " [vanilla]") : ""); + handler->PSendSysMessage("To: {} {}{}", to, + toNode ? toNode->name.c_str() : "??", + toNode ? (toNode->isCustom ? " [custom]" : " [vanilla]") : ""); + handler->PSendSysMessage("Cost: {} copper", cost); + handler->PSendSysMessage("Points: {}", uint32(wps.size())); + + if (wps.size() < 2) + { + handler->PSendSysMessage("Not enough waypoints for distance metrics (need >= 2)."); + return true; + } + + auto dist3 = [](P const& a, P const& b) -> float + { + float dx = b.x - a.x, dy = b.y - a.y, dz = b.z - a.z; + return std::sqrt(dx*dx + dy*dy + dz*dz); + }; + + float total = 0.f, minSeg = 0.f, maxSeg = 0.f; + for (size_t i = 1; i < wps.size(); ++i) + { + float d = dist3(wps[i - 1], wps[i]); + total += d; + if (i == 1) { minSeg = d; maxSeg = d; } + else { minSeg = std::min(minSeg, d); maxSeg = std::max(maxSeg, d); } + } + + handler->PSendSysMessage("Total distance: {:.1f} yd", total); + handler->PSendSysMessage("Min segment: {:.1f} yd Max segment: {:.1f} yd", minSeg, maxSeg); + + if (toNode && wps.back().mapId == toNode->mapId) + { + P last = wps.back(); + P dest{ 0, toNode->mapId, toNode->x, toNode->y, toNode->z }; + handler->PSendSysMessage("Last point distance to ToTaxiNode: {:.2f} yd", dist3(last, dest)); + } + else if (toNode) + { + handler->PSendSysMessage( + "Last waypoint is on map {} but ToTaxiNode is on map {} — different continents.", + wps.back().mapId, toNode->mapId); + } + return true; + } + + static bool HandleTaxiPathValidate(ChatHandler* handler, uint32 pathId) + { + ValidationReport rep = ValidatePath(pathId, handler->GetSessionDbcLocale()); + + for (auto const& e : rep.errors) + handler->PSendSysMessage("[ERROR] {}", e.c_str()); + for (auto const& w : rep.warnings) + handler->PSendSysMessage("[warn] {}", w.c_str()); + + handler->PSendSysMessage( + "Validation: {} errors, {} warnings — {}", + uint32(rep.errors.size()), uint32(rep.warnings.size()), + rep.ok() ? "OK" : "FAIL"); + return true; + } + + static bool HandleTaxiPathGoto(ChatHandler* handler, uint32 pathId, uint32 nodeIndex) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player."); + return false; + } + + QueryResult r = WorldDatabase.Query( + "SELECT map_id, position_x, position_y, position_z " + "FROM custom_taxi_path_nodes WHERE path_id = {} AND node_index = {}", + pathId, nodeIndex); + if (!r) + { + handler->SendErrorMessage( + "No waypoint found for path={} nodeIndex={}.", pathId, nodeIndex); + return false; + } + Field* f = r->Fetch(); + uint32 mapId = f[0].Get(); + float x = f[1].Get(), y = f[2].Get(), z = f[3].Get(); + + player->SaveRecallPosition(); + player->TeleportTo(mapId, x, y, z, player->GetOrientation()); + handler->PSendSysMessage( + "[taxi-builder] teleported to path {}, NodeIndex {} map={} ({:.2f}, {:.2f}, {:.2f})", + pathId, nodeIndex, mapId, x, y, z); + return true; + } + + // MVP test: validate the route, then teleport the GM to the first + // waypoint with orientation toward the second. A real flight via taxi + // splines requires the new path to live in TaxiPath/TaxiPathNode DBC, + // which is the moonwell-taxi CLI's job — until then this gives a + // hand-on preview that the route is sane and reachable. + static bool HandleTaxiPathTest(ChatHandler* handler, uint32 pathId) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player."); + return false; + } + + ValidationReport rep = ValidatePath(pathId, handler->GetSessionDbcLocale()); + if (!rep.ok()) + { + handler->PSendSysMessage("Refusing to test: validation has {} errors.", uint32(rep.errors.size())); + for (auto const& e : rep.errors) + handler->PSendSysMessage(" [ERROR] {}", e.c_str()); + handler->PSendSysMessage("Run `.taxi path validate {}` for full report.", pathId); + return false; + } + for (auto const& w : rep.warnings) + handler->PSendSysMessage(" [warn] {}", w.c_str()); + + auto wps = LoadPathWaypoints(pathId); + if (wps.size() < 2) + { + handler->SendErrorMessage("path {} has fewer than 2 waypoints.", pathId); + return false; + } + + float orientation = player->GetOrientation(); + if (wps[0].mapId == wps[1].mapId) + orientation = std::atan2(wps[1].y - wps[0].y, wps[1].x - wps[0].x); + + player->SaveRecallPosition(); + player->TeleportTo(wps[0].mapId, wps[0].x, wps[0].y, wps[0].z, orientation); + + handler->PSendSysMessage( + "[taxi-builder] dropped at first waypoint of path {} ({} total). " + "Step through with `.taxi path goto {} `. " + "Real taxi flight needs `moonwell-taxi export-dbc` + worldserver/client restart.", + pathId, uint32(wps.size()), pathId); + return true; + } + + static bool HandleTaxiRecordStart(ChatHandler* handler, uint32 pathId) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player."); + return false; + } + + if (!WorldDatabase.Query("SELECT 1 FROM custom_taxi_paths WHERE id = {}", pathId)) + { + handler->SendErrorMessage( + "Path {} does not exist. Create it first with `.taxi path create`.", pathId); + return false; + } + + ObjectGuid const guid = player->GetGUID(); + if (auto it = g_recording.find(guid); it != g_recording.end()) + { + handler->SendErrorMessage( + "You are already recording path {}. Run `.taxi record finish` or `.taxi record cancel` first.", + it->second.pathId); + return false; + } + + uint32 const baseline = CountPathPoints(pathId); + g_recording[guid] = { pathId, baseline }; + + handler->PSendSysMessage( + "[taxi-builder] recording started for path {} (existing points: {}). " + "Use `.taxi record point [\"comment\"]` and `.taxi record finish` when done.", + pathId, baseline); + return true; + } + + static bool HandleTaxiRecordPoint(ChatHandler* handler, Optional comment) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player position."); + return false; + } + + ObjectGuid const guid = player->GetGUID(); + auto it = g_recording.find(guid); + if (it == g_recording.end()) + { + handler->SendErrorMessage( + "You are not recording. Start with `.taxi record start ` first."); + return false; + } + + uint32 const pathId = it->second.pathId; + std::optional const idx = NextNodeIndex(pathId); + if (!idx) + { + handler->SendErrorMessage("Failed to compute next node_index for path {}.", pathId); + return false; + } + + std::string esc = comment ? std::string(*comment) : std::string(); + WorldDatabase.EscapeString(esc); + + WorldDatabase.Execute( + "INSERT INTO custom_taxi_path_nodes " + "(path_id, node_index, map_id, position_x, position_y, position_z, comment) " + "VALUES ({}, {}, {}, {}, {}, {}, '{}')", + pathId, *idx, player->GetMapId(), + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + esc); + + handler->PSendSysMessage( + "[taxi-builder] path={} point #{} map={} ({:.2f}, {:.2f}, {:.2f}){}{}", + pathId, *idx, player->GetMapId(), + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + comment ? " // " : "", comment ? comment->c_str() : ""); + return true; + } + + static bool HandleTaxiRecordUndo(ChatHandler* handler) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player."); + return false; + } + + ObjectGuid const guid = player->GetGUID(); + auto it = g_recording.find(guid); + if (it == g_recording.end()) + { + handler->SendErrorMessage("You are not recording."); + return false; + } + + uint32 const pathId = it->second.pathId; + QueryResult r = WorldDatabase.Query( + "SELECT MAX(node_index) FROM custom_taxi_path_nodes WHERE path_id = {}", pathId); + if (!r || r->Fetch()[0].IsNull()) + { + handler->SendErrorMessage("Path {} has no waypoints to undo.", pathId); + return false; + } + uint32 const lastIdx = r->Fetch()[0].Get(); + if (lastIdx < it->second.baselinePointCount) + { + handler->SendErrorMessage( + "All points from this recording session were already removed (baseline={}).", + it->second.baselinePointCount); + return false; + } + + WorldDatabase.Execute( + "DELETE FROM custom_taxi_path_nodes WHERE path_id = {} AND node_index = {}", + pathId, lastIdx); + + handler->PSendSysMessage("[taxi-builder] removed point #{} from path {}.", lastIdx, pathId); + return true; + } + + static bool HandleTaxiRecordFinish(ChatHandler* handler) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player."); + return false; + } + + ObjectGuid const guid = player->GetGUID(); + auto it = g_recording.find(guid); + if (it == g_recording.end()) + { + handler->SendErrorMessage("You are not recording."); + return false; + } + + uint32 const pathId = it->second.pathId; + uint32 const total = CountPathPoints(pathId); + uint32 const recorded = total - it->second.baselinePointCount; + g_recording.erase(it); + + handler->PSendSysMessage( + "[taxi-builder] recording finished. Path {} now has {} waypoints (added {} this session).", + pathId, total, recorded); + handler->PSendSysMessage( + "Run `.taxi path report {}` or `.taxi path validate {}` to review.", + pathId, pathId); + return true; + } + + static bool HandleTaxiRecordCancel(ChatHandler* handler) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player."); + return false; + } + + ObjectGuid const guid = player->GetGUID(); + auto it = g_recording.find(guid); + if (it == g_recording.end()) + { + handler->SendErrorMessage("You are not recording."); + return false; + } + + uint32 const pathId = it->second.pathId; + uint32 const baseline = it->second.baselinePointCount; + g_recording.erase(it); + + // Drop only the points added during this session; pre-existing + // waypoints are kept so a partial editing session does not destroy data. + WorldDatabase.Execute( + "DELETE FROM custom_taxi_path_nodes WHERE path_id = {} AND node_index >= {}", + pathId, baseline); + + handler->PSendSysMessage( + "[taxi-builder] recording cancelled for path {}; rolled back to {} waypoints.", + pathId, baseline); + return true; + } + + static bool HandleTaxiMarkersSpawn(ChatHandler* handler, uint32 pathId) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player."); + return false; + } + + if (!sObjectMgr->GetGameObjectTemplate(MARKER_GO_ENTRY)) + { + handler->SendErrorMessage( + "Marker GO entry {} is missing from gameobject_template — " + "edit MARKER_GO_ENTRY in mod-taxi-builder source and rebuild.", + MARKER_GO_ENTRY); + return false; + } + + auto wps = LoadPathWaypoints(pathId); + if (wps.empty()) + { + handler->SendErrorMessage( + "Path {} has no waypoints; record some first with `.taxi record start {}`.", + pathId, pathId); + return false; + } + + // Replace any previously spawned markers for this path so consecutive + // spawn calls don't pile up duplicates. + DespawnMarkersForPath(player, pathId); + + uint32 const playerMap = player->GetMapId(); + uint32 spawned = 0, skipped = 0; + for (auto const& wp : wps) + { + // GameObjects only become visible on grids that are actually + // loaded around someone, so spawning markers on a different + // continent is silently useless. + if (wp.mapId != playerMap) + { + ++skipped; + continue; + } + GameObject* go = player->SummonGameObject( + MARKER_GO_ENTRY, wp.x, wp.y, wp.z, /*ang*/ 0.f, + /*rot0*/ 0.f, /*rot1*/ 0.f, /*rot2*/ 0.f, /*rot3*/ 1.f, + MARKER_RESPAWN_SECONDS); + if (go) + { + g_markers[pathId].push_back(go->GetGUID()); + ++spawned; + } + } + + handler->PSendSysMessage( + "[taxi-builder] spawned {} markers for path {} on map {} ({} skipped on other maps). " + "Auto-despawn in {} s. Use `.taxi markers clear {}` to remove now.", + spawned, pathId, playerMap, skipped, MARKER_RESPAWN_SECONDS, pathId); + return spawned > 0; + } + + static bool HandleTaxiMarkersClear(ChatHandler* handler, uint32 pathId) + { + Player* player = handler->GetSession() ? handler->GetSession()->GetPlayer() : nullptr; + if (!player) + { + handler->SendErrorMessage("This command requires a player."); + return false; + } + + auto it = g_markers.find(pathId); + if (it == g_markers.end()) + { + handler->PSendSysMessage("[taxi-builder] no active markers for path {}.", pathId); + return true; + } + uint32 const count = uint32(it->second.size()); + DespawnMarkersForPath(player, pathId); + handler->PSendSysMessage("[taxi-builder] cleared {} markers for path {}.", count, pathId); + return true; + } + + static bool HandleTaxiExportDbc(ChatHandler* handler) + { + handler->PSendSysMessage( + "[taxi-builder] export dbc stub: run `moonwell-taxi export-dbc` from CLI"); + return true; + } +}; + +void AddSC_taxi_builder_commandscript() +{ + new taxi_builder_commandscript(); +} diff --git a/modules/mod-taxi-builder/src/cs_taxi_log.cpp b/modules/mod-taxi-builder/src/cs_taxi_log.cpp new file mode 100644 index 0000000..8efa51a --- /dev/null +++ b/modules/mod-taxi-builder/src/cs_taxi_log.cpp @@ -0,0 +1,163 @@ +/* + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * mod-taxi-builder: verbose runtime logging of taxi-related events. + * Hooks creature gossip interactions (catches the moment a player clicks a + * flight master — useful when the client crashes on dialog open) and polls + * Player::IsInFlight() to log start/mid/end of taxi flights with positions + * and current path. Output goes to Server.log via the standard logger; the + * channel is `server.loading` so it always passes the default Appender + * thresholds, but the `[taxi-log]` prefix lets you grep cleanly. + */ + +#include "Chat.h" +#include "Creature.h" +#include "Log.h" +#include "ObjectGuid.h" +#include "Player.h" +#include "ScriptMgr.h" + +#include + +namespace +{ + struct TaxiLogState + { + bool inFlight = false; + uint32 lastPath = 0; + uint32 nextLogMs = 0; + float lastX = 0.f; + float lastY = 0.f; + float lastZ = 0.f; + uint32 lastMapId = 0; + }; + + std::unordered_map g_taxiLog; + + constexpr uint32 PROGRESS_LOG_INTERVAL_MS = 1000; +} + +class taxi_builder_gossip_log : public AllCreatureScript +{ +public: + taxi_builder_gossip_log() : AllCreatureScript("taxi_builder_gossip_log") { } + + bool CanCreatureGossipHello(Player* player, Creature* creature) override + { + if (!player || !creature) + return false; + + bool const isFlightMaster = creature->IsTaxi(); + LOG_INFO("server.loading", + "[taxi-log] GossipHello: player={} (lvl {}) creature={} entry={} guid={} " + "isFlightMaster={} pos=({:.2f},{:.2f},{:.2f}) map={} npcflag=0x{:X}", + player->GetName(), uint32(player->GetLevel()), + creature->GetName(), creature->GetEntry(), + creature->GetGUID().ToString(), + isFlightMaster ? "YES" : "no", + creature->GetPositionX(), creature->GetPositionY(), creature->GetPositionZ(), + creature->GetMapId(), + creature->GetUInt32Value(UNIT_NPC_FLAGS)); + // returning false leaves default handling in place (we just observed) + return false; + } +}; + +class taxi_builder_flight_log : public PlayerScript +{ +public: + taxi_builder_flight_log() : PlayerScript("taxi_builder_flight_log") { } + + void OnPlayerLogout(Player* player) override + { + if (player) + g_taxiLog.erase(player->GetGUID()); + } + + void OnPlayerUpdate(Player* player, uint32 diff) override + { + if (!player) + return; + + bool const nowFlying = player->IsInFlight(); + auto it = g_taxiLog.find(player->GetGUID()); + + // Fast path: not flying and never started — skip allocation. + if (!nowFlying && it == g_taxiLog.end()) + return; + + TaxiLogState& state = (it != g_taxiLog.end()) ? it->second : g_taxiLog[player->GetGUID()]; + + // START of flight + if (nowFlying && !state.inFlight) + { + uint32 path = player->m_taxi.GetCurrentTaxiPath(); + auto const& nodes = player->m_taxi.GetPath(); + std::string nodeList; + for (uint32 n : nodes) + { + if (!nodeList.empty()) nodeList += ","; + nodeList += std::to_string(n); + } + LOG_INFO("server.loading", + "[taxi-log] START player={} path={} nodes=[{}] start=({:.1f},{:.1f},{:.1f}) map={}", + player->GetName(), path, nodeList, + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + player->GetMapId()); + + state.inFlight = true; + state.lastPath = path; + state.lastX = player->GetPositionX(); + state.lastY = player->GetPositionY(); + state.lastZ = player->GetPositionZ(); + state.lastMapId = player->GetMapId(); + state.nextLogMs = PROGRESS_LOG_INTERVAL_MS; + return; + } + + // END of flight + if (!nowFlying && state.inFlight) + { + LOG_INFO("server.loading", + "[taxi-log] END player={} lastPath={} end=({:.1f},{:.1f},{:.1f}) map={}", + player->GetName(), state.lastPath, + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + player->GetMapId()); + state.inFlight = false; + return; + } + + // Mid-flight throttled progress log (~1s) + if (!nowFlying) + return; + + if (state.nextLogMs > diff) + { + state.nextLogMs -= diff; + return; + } + state.nextLogMs = PROGRESS_LOG_INTERVAL_MS; + + float dx = player->GetPositionX() - state.lastX; + float dy = player->GetPositionY() - state.lastY; + float dz = player->GetPositionZ() - state.lastZ; + float moved = std::sqrt(dx*dx + dy*dy + dz*dz); + + LOG_INFO("server.loading", + "[taxi-log] MID player={} path={} pos=({:.1f},{:.1f},{:.1f}) map={} dMoved={:.1f}yd", + player->GetName(), state.lastPath, + player->GetPositionX(), player->GetPositionY(), player->GetPositionZ(), + player->GetMapId(), moved); + + state.lastX = player->GetPositionX(); + state.lastY = player->GetPositionY(); + state.lastZ = player->GetPositionZ(); + state.lastMapId = player->GetMapId(); + } +}; + +void AddSC_taxi_builder_logger() +{ + new taxi_builder_gossip_log(); + new taxi_builder_flight_log(); +} diff --git a/modules/mod-taxi-builder/src/mod_taxi_builder_loader.cpp b/modules/mod-taxi-builder/src/mod_taxi_builder_loader.cpp new file mode 100644 index 0000000..e58e29d --- /dev/null +++ b/modules/mod-taxi-builder/src/mod_taxi_builder_loader.cpp @@ -0,0 +1,16 @@ +/* + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * mod-taxi-builder entry point. AzerothCore's module loader calls + * Addmod_taxi_builderScripts() during startup; this function registers + * every script the module owns. + */ + +void AddSC_taxi_builder_commandscript(); +void AddSC_taxi_builder_logger(); + +void Addmod_taxi_builderScripts() +{ + AddSC_taxi_builder_commandscript(); + AddSC_taxi_builder_logger(); +} diff --git a/tools/moonwell-taxi/.venv/bin/python b/tools/moonwell-taxi/.venv/bin/python new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/tools/moonwell-taxi/.venv/bin/python @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/tools/moonwell-taxi/.venv/bin/python3 b/tools/moonwell-taxi/.venv/bin/python3 new file mode 120000 index 0000000..ae65fda --- /dev/null +++ b/tools/moonwell-taxi/.venv/bin/python3 @@ -0,0 +1 @@ +/usr/bin/python3 \ No newline at end of file diff --git a/tools/moonwell-taxi/.venv/bin/python3.12 b/tools/moonwell-taxi/.venv/bin/python3.12 new file mode 120000 index 0000000..b8a0adb --- /dev/null +++ b/tools/moonwell-taxi/.venv/bin/python3.12 @@ -0,0 +1 @@ +python3 \ No newline at end of file diff --git a/tools/moonwell-taxi/.venv/lib64 b/tools/moonwell-taxi/.venv/lib64 new file mode 120000 index 0000000..7951405 --- /dev/null +++ b/tools/moonwell-taxi/.venv/lib64 @@ -0,0 +1 @@ +lib \ No newline at end of file diff --git a/tools/moonwell-taxi/.venv/pyvenv.cfg b/tools/moonwell-taxi/.venv/pyvenv.cfg new file mode 100644 index 0000000..b6354b8 --- /dev/null +++ b/tools/moonwell-taxi/.venv/pyvenv.cfg @@ -0,0 +1,5 @@ +home = /usr/bin +include-system-site-packages = false +version = 3.12.3 +executable = /usr/bin/python3.12 +command = /usr/bin/python3 -m venv /home/sindo/moonwell-core/tools/moonwell-taxi/.venv diff --git a/tools/moonwell-taxi/README.md b/tools/moonwell-taxi/README.md new file mode 100644 index 0000000..f4cc8ad --- /dev/null +++ b/tools/moonwell-taxi/README.md @@ -0,0 +1,87 @@ +# moonwell-taxi + +External CLI that reads custom flight data from `acore_world` and the +three vanilla taxi DBCs, then generates merged DBCs ready to drop into +both the server's `dbc/` directory and the client patch. + +## Quick start + +```bash +# 1. install deps (PyMySQL only) +pip install -r requirements.txt + +# 2. point at your vanilla DBC source and run the export +./bin/moonwell-taxi export-dbc \ + --vanilla-dir /home/sindo/moonwell-core \ + --output-dir /home/sindo/moonwell-core/build/taxi/export + +# Output: +# /server/dbc/TaxiNodes.dbc +# /server/dbc/TaxiPath.dbc +# /server/dbc/TaxiPathNode.dbc +# /client/DBFilesClient/TaxiNodes.dbc +# /client/DBFilesClient/TaxiPath.dbc +# /client/DBFilesClient/TaxiPathNode.dbc +# +# A sha256 manifest is written next to each half; the tool fails with a +# non-zero exit if server and client copies do not match. + +# 3. pack the client half into an MPQ patch for native taxi UI +./bin/moonwell-taxi build-client-patch + +# Output: +# /client/patch-Z.MPQ +# +# Copy that MPQ to the WoW 3.3.5a client's Data directory, delete the +# client's Cache directory, then restart Wow.exe. Loose DBFilesClient/*.dbc +# files are not enough for the stock 3.3.5a client taxi UI. +``` + +## DB connection + +Reads connection params from environment variables (which `acore.sh` and +`import-custom-sql.sh` already populate from `.env`): + +- `DOCKER_DB_EXTERNAL_PORT` (default 3306) +- `DOCKER_DB_ROOT_PASSWORD` (default "password") +- `MOONWELL_TAXI_DB_HOST` (default 127.0.0.1) +- `MOONWELL_TAXI_DB_USER` (default root) +- `MOONWELL_TAXI_DB_NAME` (default acore_world) + +CLI flags override env: `--db-host`, `--db-port`, `--db-user`, +`--db-password`, `--db-name`. + +## Subcommands + +- `export-dbc` — fully implemented: merges vanilla + custom into new DBCs, + writes server and client copies, prints sha256 for both. Refuses to run + if `--vanilla-dir` already contains records in the custom-id space + (TaxiNode id ≥ 441, TaxiPath id ≥ 1979) — that means the directory + was already merged once and re-running would compound rows. +- `build-client-patch` — packs the exported client DBCs into a minimal MPQ + patch with internal paths `DBFilesClient\TaxiNodes.dbc`, + `DBFilesClient\TaxiPath.dbc`, and `DBFilesClient\TaxiPathNode.dbc`. + This is required for the native flight-master map UI to see custom + taxi nodes; otherwise the client can crash when the server sends a + custom current-node id. +- `thin --path [--min-yd 50] [--dry-run]` — drops waypoints that sit + within `min-yd` (XY) of the previously kept point, preserves first and + last, renumbers `node_index` from 0. Use it when an in-game recording + produced too-dense waypoints (chat shows lots of + `segment N->M is X.X yd (< 40 yd ...)` warnings) — server filters those + via `IsNodeIncludedInShortenedPath` and the spline goes haywire. + Backs up to `custom_taxi_path_nodes_backup__` before + deleting; the rollback SQL is printed at the end. +- `dump-vanilla [--limit N]` — debug helper: parses a `.dbc` file + and prints header + first N records as raw uint32. Useful when adding + new schemas. + +The other subcommands listed in the spec (build-mpq, deploy-server, +backup, rollback, diff) are not part of the MVP and return a +not-implemented error today. + +## Default paths + +`--vanilla-dir` defaults to `/var/client-data-vanilla`. Keep that +directory as a clean snapshot. `redeploy.sh` writes merged files into +`/var/client-data-live/dbc`, not back into the vanilla snapshot. diff --git a/tools/moonwell-taxi/__pycache__/moonwell_taxi.cpython-312.pyc b/tools/moonwell-taxi/__pycache__/moonwell_taxi.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..033ea66b8cba98c249674591bd20fd6a52af3978 GIT binary patch literal 47472 zcmdqK34B|}nJ0*oxKG}f_$X2oAzmVN-=;`Pq7Le!Y+W`4@qrQ~UVZ>A5dmW+?fHeY z)hjA?TJSm^L+R{>UTZeYCckZ_JKIsF(-X~h&u;*M4iOAz*FEEz-*|Usm$uZMSTobv z|5xt-NI|ya-tFDlC$UhkUcIV%RbPGe)pylj*vu+ifAa6tSN8wDO7*YlM!rg<$ip_R zN_AVstIn!;HLn>^pH;J8%~=im)t=R|U)@<9`_-S-<5xS7H)uF(P}4iQ0ppw^*1{XkT6yDHo8QKp!g`hJterQVb@(09os&18b@^S=otw9u&DW^>1-$hYl|R2z z^`6wQ_tawKY$0zuTg2PX7W0m?CA{-&DepR4#=Fm!^Z92h_yT`rzlJY-MR#@;N?9ep zeYT1(@^gH#zZyBJ`}6n`=sg@@w(Cj<1)}b-V}n4Ss`0wO_@rdqu_9y{8es zOKE8%-+;U;+yf;g1_~+Z^d65{x;xmBmN5fHT^~WCY0FBui@)(U(m1PH{-sA z--72`@wW|s+oil_eg~ed=Xc_sp6|ln%6fJqZ4dsQ!CyQ6sE!hTFWzhAJJ4I5ERA}w z3u$fqzE@OdH}LxrZsZRj+{AYy+{~}>H}VIOU+nEe$h&1l+F_(^U6FPKY1_o!@^ko> z9^7x|kD~XEp~uDEK8~~+?XAyq}arKq2OJJ4t z1Gsq(&-e1@q}-=p(Vy+`ck*Yh)~i&44e->3zYck%WQhE_dQ9Uv?|YRRb1(UZ0s{j+t{2aGhIxOG zYcW&&m=6zISTaTF5nk*FHgea5K*-Mp{lZIrf%6UVT;D*zKNRBnhDQQ^JV%l_c-hys zX>${D1p3Fg&}IJ^H|PuXU2Zm;doQzsxQl@ypD@Pt4-0t0jRyTZcX5o{(7d5}laC7o zx0}rzuD10}7spWDWxtR23)@*5r4RZp1^OsLSKnoyaBk!Ci0$Gou6+*8T&rXh{C&d$ z@9i5NMH5E@L!k|A%Cr7}e?We;@|pBrFc9{0(leA_PmKw3BlvPLgGVVWc+o3nrWT16 z(F?(l5E#0|T^tzhyUI}p@r`B8S|=4I79dtJ+%F{tSq*aO%EO*hhnqrvVGz9-Lf47a z1i5cfcgTwsVshwBdv_;;{auekOqJY z42}#0`U0V5^S)t$8y*$#T=4t3kniFEN}|^Wd}G6-Ay%q1UKuc=Zs6+MHll1)54hO2 zncLeU^;%{iyE|D%Iao159m7MRz!0EI%(mj`vEiWiwEQN=^$!gDLdtm1!!w`t@a)qb z_V@<<-T~jxrE{&D0aVghN&`9E-yigc#6h6~l+4~iU-0TmC^|x5*CF_Qp;5u#-FdDp zQ(pZ6z@@*DYabX0_=frb7p&>Z+!I?@pXtBWr}SSVw@(-zlsmqW>mAO-JBEWHjDl}q zKx)~FTG+HkhSpYPm^NIjuC{wOTK}wm)P)A@`z8Mew0$=kuiTnD743;PMdgN$k#XE z3kGqcqvi&I5>5)!#V_qRB8|}5!Lf1m1EH2DI7 z#rovoJw!mrsV{+yBeH8;#cRjaA*Jtl-Fs5JQ}z6QJl8}t45e|E0%yof%M7d9Zjh2Dy&BHEoOEu!UhVH@7p-q@m3 z-RRcg$w`&R+9S|d1XQRtpjJ6mA7O-5AJIpCG)@8aPT;-&Zk;Od+u!2WiCckS3?Qf% zfJFQrL)y|OjE#gaUcj7bUC{66(|G}!L9NnnTUzZ+>qGv*kyi1iErh(#OmFXhoX52YgNkXp7u{2o!a-Y4W%#XQR|XhWi5{DiXNhU zDuFsEZMy6WUiQ+G)F)A&^h`xFlKS)EA_4-PaSacUh^VeefP!S@;xS+{?RtgYT5-u# z62L}xg~n^e#b<-p1_GQAz)8Fq2%*e~o&c>Be*l}jh$f>UPAld|w)kidxCO!cW z83_mhpm@rF)Wkw|5762Ku+jjm%sqks@bZ6nna!>LW9VN#uT?!(w>(y#PwRcbzChq3 zBZ_2TBoL|+$`E*TX@lS&8SwS_)B4d7&>-nNaT!lLFp0e#C(az}_4c;!J>2Cnux=Iz zBc=5OZvZR$X-k`jh~OFkA;PCMeV5aEpx9CWlhEcMFk_=6E_}jflyw$=!8Z^9%&bKd zU1H!Wyj^jtBH9<*m~_=IxSA8L=A>)=#KDxkWWipQuvf(lNqhY#Dy_a(J*ioAR>ewZ ze0NsOwk5bt3FqcXeacl7T@zdLW>dmdKXXt@%AfY%DvSCPu9_MBtm|F#?3KjYXA-XV zd+iBV*Zs1D^W>C%$v|Z;8C6!-#9?6#``T+*R)cBnRsYyB(4wJbpv7TefT0Pr)Z^Mq zgjH0)KRyD0h#r{A&SKgSUJx%{8_@taNCQg1z=l*qDF99YKo(etsQ?uRwx0|XSPJkK z-a|QtFjj`F_cZ`322={@u>35C2s&~-x?DgW$&+y;)|Wn=Dx?7V6$O~3&%h6F4xx7O ziYVCkEnvd8wU^Wp-FMXgDDS_LupV|CA_RU!)AA$WHq}Rk6u58+3_came5~G?R(E^! z!g^##=lKAfLqKtWoYB5eS~ueByPDPx_=f3{gN~bqRPu$uW+n#VY z&6XuxJLX33YkvHdgzM1#aKd?pz_%=VD&blk8=Y0(3D2HQ)a{`nTNAF%`{so61acTH zH_cP#>22>DE0=6&(IdOc>b|*SYDd)d?G4f3TU(>+V(rr}$1P1W`422jab1%L%{oTW zWTQw0j!!T+&a9RZl?(~In$|_Z5>k*NuaTd99jhfEUyF72vmr(Il#EHEAY~c( zj_WXs^bs9q(Rs`Wz;lSegIzpkLIxQsbH^yno#iN1Of|5%V`6iMrC?o0St!S%%pEaj z_S~^%^0T>PW1(D+{tD4~E3Sw^?iI|P7l4@cK+N_YfmYsdvjT`5kgK@qVf#1Q8qp3) zq;{KtN-quhd4Wxr-FQ>jL&0_mc2GdHfItL>Ky=xPiQ~x=I*^(+KTBA+OAv+yp`8-) zMu%wDun8qRgA|WZIDnrOQ%9Umo%CQIg0vpXLtrRK-7nD&oJc&dxx@QKxtnklE`+c%R!Qdyd%Tc{DOD5-y1@&A4l%r!(Wh=DxfQ)X`(%4woLk8 z^&md!n=(wQm$WL2b<+Qu=L3s%;()ZM(f&M`wg69#jE1~)w`_5vR*ydSJUNtUuFYCJwDQwV)lFTW=8~#U^E{9ap#om}9rZ2E4WkZ7 zfuV-H5aaq%CF6z=NNn*^=rd!;A*W^90A%u~D%E+6!?!c!j9p6<5R} zw@0mtM@ff!eL)MonxpSt`jAd7-NRH8V;gYgos#PIs47(!c3>AtcTpz}_GUR3j zVPK_fd>)0NGBH156>_X!^O9QNB38VGq%ZZh`UbbGEqZyhmZfUsR9+V`UD8p@2qWe0 z6SfI5+_(pXXc)Ie^cZ3LO-CO{C5$o9=(s&%$LIjk#vKvIE$xjwY?o{pZRfas+!3mh zzb@j;w1GG9d2$=%-*MNn`d#t}iuI#zcO>r%*PXwj?)I$VnOh2(_MVh;yfC7^LW{q^6l1l;l_-gMV2 z_ZuKweWfW!E(NMLsm6;UMORv$%o8b;zZ9k9&SCuA9Lh*NwG5>{A%n5fzf9Z5tbH0a zAZc884IyIMamToQdAvr|stT&F;prJ{w$-XJ>vioJ71$=WaL03u`|RVu_zYkl!xrb4 zTEW-syBv7QFY>fU!Q31i5&S`7DhI)S9q>1e4h2Gt%^7SK8qqNy5$5;k#O0Z-(V2iA z2}dcQN&DDZPx~N|1n@kz)r-7bwwd?zzN<+az?TA#DwsCM=Gw&`Pf1!g zfcvy|(084oezw*NH2uXL+kvk3&aM;Qlig>#(k9~U`UJr@Ml|p!`1auIVuN=*1X7w& z0OC9Qx(|1GyH0nX>^+$-plu&ZIN9B^AL*xhx_jA1O?-5q{p0~}S5NPWGv2Nv$9m7C zi|B={)XuKMUANw?k@o%F9qByrThsc%t9(F6>xMz; zr}a!E;3*bPP@kQoBJ_-lm3D~xeBygB=C(jHD{W<|=rREUgS3tHCkT7-Hm`3~2o4L> zt7Bm3@@b3sobgMstB>;~&I z)>Sb<%qiW7S@T4>Lt5R>7-}+2wc=M??@bDOMj*z}tN05}0+c3HA6lK$g|8i&=vpi) zjaJWBHs1}-Zh9{~*Z%HTla;%ZMSCWWF7}*^_x2@w_+RGx@4My;I^+5NiNgp|1w|7_ zKD1R#>|3-Kzjk$E|6*lbY&2Qf^oc58Uy(4DPFkjWQ;ve^-l?z9KF4v>K4qT{%^NFH z#=-?7a#VmNvuJfp>t5?te#+-pwI!@=OD6p)^COksWWk2SSrrY%jwc;!XZFk;Pc-aK zIQF1Gds%d2!p_B9346_a<1_PnPsbb1B?InxH@KgzgZi1bxcz*aWLWPSj7Ho``wr4zIy+~WXtiS>jd}@ zI`fUb>26W6`3~wP-9(8SS9l*#Dvw-4-tz zEt5x*# zX~|;b9k*xYwPocs<;d#@5qpPSeD)aHWrBb=zeiAj3uKv7 zBKk2x1wr3Q0CrTr%u+s;1RorHb2z z{PBX!7f15J1EF;t7kb&Ekm-fX@>t|N<3-ET6#{0wIP*;rv-}+(ho{jGB@tJokZn^+ zBc;5Nk^RIYV;2+QgQOYyeLU09vq%w3yNr7ayDw(<7vy)x%Roey(UbF-J>%u$72}oT zs{j+__*WspNL6H2uB`=aSQtbWtbj;cBp(oIkF4SyZ|RmnU}YARDg+#r5!6SjA_c58 z=ZeytRNC9hX7KB3d|zdx5dBNvx2&IUd=uQs3_(n@-nH^=x&M^@q1vrVs@zMIs=o0z zD@t>(EDh2&k;K%DJ0${>#XR{v;bIPwH7pKsr#$=ui-x zc4Pt}iKmhp0^Vk#{=qlI{(4%we(PzA4nDaQ&W{9DAFH;~83DFm*H3unZr{E9;{S;iHfY?KfmJ|tAJ(NBM zg91;f7b)mN;Ne7)@2ix+s4_iM%t%{Ax{1+zX{$t>i+7g(!H^eKV_zWCY~tsufX!s(#J?bM`i~hHrvD=v1aJmNP*4GMmosh%@qXU6SpFTXcqNhNP;K4 zfwz8vzu+f~L`+%o7c8q1mQ_g$2brD7i+f|w1H)=C$J|%$`yT8$s#MSsFYQd)yW+;Klp+6(V07c_V-F0R@_6Um`MI9^p84MA;-}9i zd!LV=d?8-yP1-NSjTb&}6;2PuzB=c)zdEt?aANJ@xa$ZAFn7`P%QIE+9f$5;Ozb$8 z@EnV~k53%RL8|4VygqE*Jp1BLYUZx|Nz47#Wb2V%=J(9BPallF7<0vr-zj`hur{9G zLo{;F;)bnruAg+=vm|#OzVAzJIQGke<1@bLbI8~ld-2Yu2Zf$^!SRVB2vTKL?=-yG z@K)19RiS?8lzmb+d3n*XDhK6Daw=WSmvq)o>X*eKJbgLoTKkF0VLGWM@hKN8NEFmT z8VV}>_|*0J@|FjVmR~swrTgfn2M#WFYNqwh>Dj^uYc{7=)x_H0ygY45?LKt>P-6G# zgmcHVG1@Y-ZPuSEt&CodU7u}9tZk2%?w!s{<(GWhJKxfgsO)%<-?>;;J7bK6p%CGpYHg@0-Z4dIBW{c*wK4|S&{!)C+(PY`N zc)>Ah;7S^Ps&lEFzUt8`Reoj67!Aig2k&pZUl*@Do^+pxTTi4c#Zg_tQW^CnEL^PX zfyFa(ey%prwCA2W(X{uTKiPC>(Op4r&Xmn|&TfuZZcn;*#H~9%Za{Z``iWjua8&(i zy5M+_sJNf&9o@ycpEoOT@722(iTw& z;_Zii6VxfxLXh?yqD14dap!<<@FMTq9j>KDd!-J>0_P3z%{!rQ=Nky_Mnud7%1afb zm3Tt+-OUR%n-VpfX8F1Hxfhc)?f0q^HJuYG#%q69%|t9c(8htZopc9-U^Souk6QQ^ zUe9Vm+Wln3!X4y!O!>sRmYgcR@f(M}c4$FYkih>^?EMPf*L6way%FdrJ<*d4-v3*) zm%%%JS6(u?8&r?cjF5UVI~T9cz2e||gu5W|1gnZ+QLYigDk#mt^j!+oUTv3F_ znLNBg`~`L3E)z#veWL++Etx!WETiyq9&Hx4Xc6sa)(r9bXFbpLETh+v^Qpp#g}#Tf zmAs;C4du3~R5|4gXpg)_fY9(AA~8LNp2zypP=C|bch#UaNU;)_AVs6?*+HQdmQArx`s0{w0 zmjVKA%uqk%{r$etfe@sJ-$8rRTETaXc#T7TQ9M|UL?)67LL{RSi4lD_Adj**NG5$_7jZQl`8U5h_0`z5$*(3IEenqA3CH%NW9KI-gMQtl zHsx^N9Ge<@4J@Z$Ir67Nw_m>Xax6Gs+%OZIt9|$7l(isLQt^)cO?xbuENPgsO=>5P zPwP{5*Ui9G;I*qLz*%_Pa?A3%ZNXWSaMr{+u+ANTRWZgbj_rIVbth}lKX z={u+)Z54BiR5KfVfi~^Sgn`5}A(ZgY2;@teF8?3r)?oND+?thK8liZ_E)TjkZ$W<{ z!^?R~vuqcugKi&{Xw2|-BE~SfW1KiCOm7h4M03_&S0NJE4);k8j`@pmaI!cpil7jD z5{twq0rL$!C{0$YEMiNkZP}@krcf3i2>cWd{1ofBMFVsX;ay%f--q#t%;0`W3&mZ- zd*Bw(byD8_TCPETpeJ1P25Vv!d$>cf^s>>{NdX}BWyK9>vn#)4efDZ+6h z6RVI~1l!s|rZR`hg&$KuKr8$)0*_hv8pS&hq#dZ$nH&%Jh!L5k*X)Q_?o7IO#jU#*H*EW(T|d~hXf2C&#;aSu+Y+y6d#KfH zgtpT9N&V#L^rh(anc}59sJOk+{lHQw6%ji(TR3Z)EC2bKdq?7xCz9@yaqG!cMRn{z zqM|WwEL|*G6R+QvDB3n@Nx3Sb*AlKeD1Ta9Z=8%4yng0^WfjUv&n;<@b<(h;!q3M>l`}t8Qk5#G_m?BpUGz9?5ZgPF20g6fJpt2!wv&sdOfBzs(OA+=~l5^k53 zsqhvv`Q$R+qm>_*e2*{B*#;1R5OhKtEW=Y)2)f)YU|6y?MC>hg@w!~^63)#1KKO+S zek8tkuD*^aZ2*yVoK$9W=2Bhxj2?jKnf=R8wsU3l^MQ z!pSKS_H?0OO`>2;(pmS=W+=2jQWO1QUX?1WJilAxD zP=pcP^fXREupB^%j4Q9)s3S9ZGWt@v-2(gvzLMH zGl+|T?V}hen0U0pKc)Epj3AvSSx3=0$WWZNi7XGVNV&i<3MXxB2jQ`&jb1NtfxX_e+3Ot~=10N%^`@;}?~7pIOHT}5 zFAoDIZ(0lXNZR4``i7wV>7#8~(CZb>)29t0ilPq7N0Ks zGrFa1CaYeY;IwOCTZc00f{VEO2dcl)9s0mkkkl2V#L(vYy(1}ic~V#Yf!?{KDb<^! zx+N8UVo+|z&qJ*$&&AT0HoNp|qqR#a{KPgsq@Sfav%V-ATvFjD2Hb%kk@H{}f+-Ui zCF#6D-w6Gxucy^xX>~YlVP7r|WZFJR25er~k1-pyv_-s!*o60|EsVzk#SW&3!CoV) zIc5mghi_pQ?M96FV^i}!o0?z%8T&NXHCM~<_1K(U#p)b%4XL44hv(u2oqHqJ5M8!x zDMPbgp$=Yg(Rve6%WS33$;aCpzxp7^@}dBg10!i%)?FiQcM*i4ukuUBFBm2}K3HQn{k3 zvD}WH;gEm338TWN5+{(0J%QZv^vd)syBGqMCHBXxp-mHAsU`DMdg`4!f> zqZhA0T6^S@(A+mNvVBWi+cs9r1@OrSyLoQcF0Qw${fM{y@Zs+Eo{lbV57){;n0vv~ z%vk)y!Ea(igkB6{o6Qj`5KUyI1cm?^V8z1{TObZS>s_=qFzAPYyzi1vs0{_X}^*D>~eFi>se8YtPLH*9v41)eLo4h66+5 zM%~@pe!AP+bF{PTr1wNu2PrCo*Y!)vxqbq~L#{;D1E`vjk#m zvK=*;mHxh`?WhaL=0O>IiKQ?68x%oI^58#51T?}

a*$^VaI9fPmzaJ7+Ah@%YC5 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 literal 0 HcmV?d00001 diff --git a/tools/moonwell-taxi/bin/moonwell-taxi b/tools/moonwell-taxi/bin/moonwell-taxi new file mode 100644 index 0000000..aa52164 --- /dev/null +++ b/tools/moonwell-taxi/bin/moonwell-taxi @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -euo pipefail +HERE="$( cd "$( dirname "${BASH_SOURCE[0]}" )/.." && pwd )" +exec python3 "$HERE/moonwell_taxi.py" "$@" diff --git a/tools/moonwell-taxi/moonwell_taxi.py b/tools/moonwell-taxi/moonwell_taxi.py new file mode 100644 index 0000000..4468f1f --- /dev/null +++ b/tools/moonwell-taxi/moonwell_taxi.py @@ -0,0 +1,958 @@ +#!/usr/bin/env python3 +""" +moonwell-taxi: merge custom_taxi_* DB rows with vanilla TaxiNodes / +TaxiPath / TaxiPathNode DBCs, write server and client copies, and +sha256-verify they match. + +The DBC binary format used by 3.3.5a is: + + 20-byte header: + magic char[4] = b'WDBC' + record_count uint32 + field_count uint32 + record_size uint32 (bytes per record) + sb_size uint32 (string block size) + record_count * record_size bytes of records + sb_size bytes of NUL-terminated strings (the string block) + +Field types are not encoded in the file; the schema is implicit. +For our three tables the layouts are: + + TaxiNodes (24 fields, 96 B/record): + ID uint32 + ContinentID uint32 + Pos_X float + Pos_Y float + Pos_Z float + Name_lang[16] uint32 string offsets + Name_lang_mask uint32 + MountCreatureID[2] uint32 (Horde, Alliance) + + TaxiPath (4 fields, 16 B/record): + ID, FromTaxiNode, ToTaxiNode, Cost (all uint32) + + TaxiPathNode (11 fields, 44 B/record): + ID, PathID, NodeIndex, ContinentID uint32 + Loc_X, Loc_Y, Loc_Z float + Flags, Delay, ArrivalEventID, DepartureEventID uint32 +""" + +import argparse +import hashlib +import os +import struct +import sys +from dataclasses import dataclass +from pathlib import Path + +DBC_MAGIC = b"WDBC" +DBC_HEADER_FMT = "<4sIIII" +DBC_HEADER_SIZE = 20 + +TAXI_NODES_FIELDS = 24 +TAXI_NODES_RECSIZE = TAXI_NODES_FIELDS * 4 # 96 +TAXI_PATH_FIELDS = 4 +TAXI_PATH_RECSIZE = TAXI_PATH_FIELDS * 4 # 16 +TAXI_PATH_NODE_FIELDS = 11 +TAXI_PATH_NODE_RECSIZE = TAXI_PATH_NODE_FIELDS * 4 # 44 + +# Vanilla DBC files we merge into. Filenames must match WoW's case. +DBC_FILES = ("TaxiNodes.dbc", "TaxiPath.dbc", "TaxiPathNode.dbc") +CLIENT_MPQ_PATHS = tuple(f"DBFilesClient\\{name}" for name in DBC_FILES) + +MPQ_MAGIC = b"MPQ\x1A" +MPQ_HEADER_FMT = "<4sIIHHIIII" +MPQ_HEADER_SIZE = 32 +MPQ_HASH_ENTRY_DELETED = 0xFFFFFFFE +MPQ_HASH_ENTRY_EMPTY = 0xFFFFFFFF +MPQ_FILE_EXISTS = 0x80000000 +MPQ_FILE_SINGLE_UNIT = 0x01000000 + + +def _build_mpq_crypt_table() -> list: + crypt_table = [0] * 0x500 + seed = 0x00100001 + for index1 in range(0x100): + index2 = index1 + for _ in range(5): + seed = (seed * 125 + 3) % 0x2AAAAB + temp1 = (seed & 0xFFFF) << 16 + seed = (seed * 125 + 3) % 0x2AAAAB + temp2 = seed & 0xFFFF + crypt_table[index2] = (temp1 | temp2) & 0xFFFFFFFF + index2 += 0x100 + return crypt_table + + +MPQ_CRYPT_TABLE = _build_mpq_crypt_table() + + +def mpq_hash_string(name: str, hash_type: int) -> int: + seed1 = 0x7FED7FED + seed2 = 0xEEEEEEEE + normalized = name.replace("/", "\\").upper() + for ch in normalized.encode("ascii"): + value = MPQ_CRYPT_TABLE[(hash_type << 8) + ch] + seed1 = (value ^ (seed1 + seed2)) & 0xFFFFFFFF + seed2 = (ch + seed1 + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF + return seed1 + + +def mpq_encrypt_words(words: list, key: int) -> bytes: + seed1 = key + seed2 = 0xEEEEEEEE + out = [] + for word in words: + word &= 0xFFFFFFFF + seed2 = (seed2 + MPQ_CRYPT_TABLE[0x400 + (seed1 & 0xFF)]) & 0xFFFFFFFF + encrypted = (word ^ (seed1 + seed2)) & 0xFFFFFFFF + seed1 = (((~seed1 << 21) & 0xFFFFFFFF) + 0x11111111 + (seed1 >> 11)) & 0xFFFFFFFF + seed2 = (encrypted + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF + out.append(encrypted) + return struct.pack(f"<{len(out)}I", *out) + + +def mpq_decrypt_words(data: bytes, key: int) -> list: + if len(data) % 4: + raise ValueError("encrypted MPQ table size is not uint32-aligned") + seed1 = key + seed2 = 0xEEEEEEEE + words = struct.unpack(f"<{len(data) // 4}I", data) + out = [] + for encrypted in words: + seed2 = (seed2 + MPQ_CRYPT_TABLE[0x400 + (seed1 & 0xFF)]) & 0xFFFFFFFF + plain = (encrypted ^ (seed1 + seed2)) & 0xFFFFFFFF + seed1 = (((~seed1 << 21) & 0xFFFFFFFF) + 0x11111111 + (seed1 >> 11)) & 0xFFFFFFFF + seed2 = (encrypted + seed2 + ((seed2 << 5) & 0xFFFFFFFF) + 3) & 0xFFFFFFFF + out.append(plain) + return out + + +def next_power_of_two(value: int) -> int: + n = 1 + while n < value: + n <<= 1 + return n + + +def build_mpq(files: dict, output_file: Path) -> None: + """Write a minimal MPQ v1 archive with uncompressed single-unit files.""" + names = list(files) + hash_table_size = max(8, next_power_of_two(len(names) * 2)) + hash_entries = [[0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF] for _ in range(hash_table_size)] + block_entries = [] + + cursor = MPQ_HEADER_SIZE + payload = bytearray() + for block_index, name in enumerate(names): + data = files[name] + payload.extend(data) + block_entries.append([ + cursor, + len(data), + len(data), + MPQ_FILE_EXISTS | MPQ_FILE_SINGLE_UNIT, + ]) + + slot = mpq_hash_string(name, 0) & (hash_table_size - 1) + while hash_entries[slot][3] not in (MPQ_HASH_ENTRY_EMPTY, MPQ_HASH_ENTRY_DELETED): + slot = (slot + 1) & (hash_table_size - 1) + hash_entries[slot] = [ + mpq_hash_string(name, 1), + mpq_hash_string(name, 2), + 0, + block_index, + ] + cursor += len(data) + + hash_table_pos = cursor + hash_words = [word for entry in hash_entries for word in entry] + hash_table = mpq_encrypt_words(hash_words, mpq_hash_string("(hash table)", 3)) + cursor += len(hash_table) + + block_table_pos = cursor + block_words = [word for entry in block_entries for word in entry] + block_table = mpq_encrypt_words(block_words, mpq_hash_string("(block table)", 3)) + cursor += len(block_table) + + header = struct.pack( + MPQ_HEADER_FMT, + MPQ_MAGIC, + MPQ_HEADER_SIZE, + cursor, + 0, + 3, + hash_table_pos, + block_table_pos, + hash_table_size, + len(block_entries), + ) + + output_file.parent.mkdir(parents=True, exist_ok=True) + with output_file.open("wb") as f: + f.write(header) + f.write(payload) + f.write(hash_table) + f.write(block_table) + + +def verify_mpq(files: dict, mpq_file: Path) -> None: + data = mpq_file.read_bytes() + if len(data) < MPQ_HEADER_SIZE: + raise ValueError(f"{mpq_file} too short to be an MPQ") + ( + magic, + header_size, + archive_size, + fmt_version, + _block_size, + hash_table_pos, + block_table_pos, + hash_table_size, + block_table_size, + ) = struct.unpack(MPQ_HEADER_FMT, data[:MPQ_HEADER_SIZE]) + if magic != MPQ_MAGIC or header_size != MPQ_HEADER_SIZE or fmt_version != 0: + raise ValueError(f"{mpq_file} has an unsupported MPQ header") + if archive_size != len(data): + raise ValueError(f"{mpq_file} size mismatch: header={archive_size}, actual={len(data)}") + + hash_bytes = data[hash_table_pos : hash_table_pos + hash_table_size * 16] + block_bytes = data[block_table_pos : block_table_pos + block_table_size * 16] + hash_words = mpq_decrypt_words(hash_bytes, mpq_hash_string("(hash table)", 3)) + block_words = mpq_decrypt_words(block_bytes, mpq_hash_string("(block table)", 3)) + + hash_entries = [hash_words[i:i + 4] for i in range(0, len(hash_words), 4)] + block_entries = [block_words[i:i + 4] for i in range(0, len(block_words), 4)] + for name, expected in files.items(): + slot = mpq_hash_string(name, 0) & (hash_table_size - 1) + name1 = mpq_hash_string(name, 1) + name2 = mpq_hash_string(name, 2) + for _ in range(hash_table_size): + entry = hash_entries[slot] + if entry[3] == MPQ_HASH_ENTRY_EMPTY: + break + if entry[0] == name1 and entry[1] == name2: + block_index = entry[3] + file_pos, compressed_size, file_size, flags = block_entries[block_index] + actual = data[file_pos:file_pos + compressed_size] + if flags != (MPQ_FILE_EXISTS | MPQ_FILE_SINGLE_UNIT): + raise ValueError(f"{mpq_file}: unexpected flags for {name}: 0x{flags:08X}") + if file_size != len(expected) or actual != expected: + raise ValueError(f"{mpq_file}: payload mismatch for {name}") + break + slot = (slot + 1) & (hash_table_size - 1) + else: + raise ValueError(f"{mpq_file}: hash table probe exhausted for {name}") + if hash_entries[slot][0] != name1 or hash_entries[slot][1] != name2: + raise ValueError(f"{mpq_file}: missing {name}") + + +def sort_records_by_id(dbc: "DBC") -> None: + """Keep DBC records in ascending ID order. + + AzerothCore's server loader builds an explicit ID index and tolerates + records appended out of order. The 3.3.5a client is stricter around taxi + UI lookups, so custom IDs that fill holes below the vanilla max must be + inserted into sorted order instead of left at EOF. + """ + dbc.records.sort(key=lambda r: struct.unpack_from(" None: + if not path.exists(): + return + for raw in path.read_text(encoding="utf-8").splitlines(): + line = raw.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + key = key.strip() + value = value.strip().strip('"').strip("'") + os.environ.setdefault(key, value) + + +# --------------------------------------------------------------------------- # +# DBC binary I/O # +# --------------------------------------------------------------------------- # + +@dataclass +class DBC: + """Mutable in-memory DBC: a list of record bytes + the string block.""" + + field_count: int + record_size: int + records: list # list[bytes], each exactly record_size bytes + string_block: bytearray + + @classmethod + def read(cls, path: Path) -> "DBC": + data = path.read_bytes() + if len(data) < DBC_HEADER_SIZE: + raise ValueError(f"{path} too short to be a DBC") + magic, n_rec, n_field, rec_size, sb_size = struct.unpack( + DBC_HEADER_FMT, data[:DBC_HEADER_SIZE] + ) + if magic != DBC_MAGIC: + raise ValueError(f"{path} bad magic {magic!r}, expected {DBC_MAGIC!r}") + records_blob_size = n_rec * rec_size + records_end = DBC_HEADER_SIZE + records_blob_size + sb_end = records_end + sb_size + if sb_end != len(data): + raise ValueError( + f"{path} size mismatch: header says {sb_end} bytes, file is {len(data)}" + ) + records = [ + data[DBC_HEADER_SIZE + i * rec_size : DBC_HEADER_SIZE + (i + 1) * rec_size] + for i in range(n_rec) + ] + return cls( + field_count=n_field, + record_size=rec_size, + records=records, + string_block=bytearray(data[records_end:sb_end]), + ) + + def write(self, path: Path) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("wb") as f: + f.write(struct.pack( + DBC_HEADER_FMT, + DBC_MAGIC, + len(self.records), + self.field_count, + self.record_size, + len(self.string_block), + )) + for r in self.records: + if len(r) != self.record_size: + raise ValueError( + f"record size {len(r)} != schema {self.record_size}" + ) + f.write(r) + f.write(self.string_block) + + def append_string(self, s: str) -> int: + """Append a UTF-8 string to the block and return its offset.""" + # An empty string block always starts with a NUL byte at offset 0 + # (so any record's "no string" can point to offset 0). Real DBCs + # already have this from vanilla, but we guard for safety. + if not self.string_block: + self.string_block.append(0) + offset = len(self.string_block) + self.string_block.extend(s.encode("utf-8")) + self.string_block.append(0) + return offset + + +# --------------------------------------------------------------------------- # +# Schema-specific record helpers # +# --------------------------------------------------------------------------- # + +def pack_taxinode_record( + *, + node_id: int, + map_id: int, + x: float, + y: float, + z: float, + name_offset: int, + mount_alliance: int, + mount_horde: int, + locale_slot: int, + name_lang_mask: int, +) -> bytes: + """Build a 96-byte TaxiNodes record. + + The vanilla 3.3.5a client expects locale-aware DBCs: the string lives + only in the slot that matches the file's locale (e.g. slot 8 for ruRU + DBCs), the other 15 slots stay 0, and `name_lang_mask` carries a + Blizzard-internal bitmask. Writing the string into every slot or + using a different mask trips strict clients (DragonUI / modded + Wow.exe builds crash on `LookupEntry`-style lookups with a NULL + deref at TaxiNodesEntry+12). + + Caller computes the right `locale_slot` and `name_lang_mask` by + sampling a vanilla record (see infer_taxinodes_locale_layout). + + Note: in the DBC binary, MountCreatureID[0] is the *Horde* mount and + MountCreatureID[1] is the *Alliance* mount (see ObjectMgr.cpp:7229 + `teamId == TEAM_ALLIANCE ? 1 : 0`). The taxi-builder spec text is + inverted; trust the runtime usage.""" + if not (0 <= locale_slot < 16): + raise ValueError(f"locale_slot {locale_slot} out of range 0..15") + parts = [ + struct.pack(" tuple: + """Walk vanilla TaxiNodes records and figure out (a) which locale slot + Blizzard's localized DBC uses (the one with non-zero offsets), and + (b) the typical `name_lang_mask` value. Returns (locale_slot, mask). + Falls back to (8, 0xFF00FE) — ruRU + observed vanilla mask — if the + file is empty.""" + mask_counts = {} + slot_counts = [0] * 16 + for r in dbc.records: + for i in range(16): + off = struct.unpack_from(" bytes: + rec = struct.pack(" bytes: + rec = struct.pack( + " int: + """Scan `records` and return the max uint32 read at byte_offset.""" + best = 0 + for r in records: + v = struct.unpack_from(" best: + best = v + return best + + +# --------------------------------------------------------------------------- # +# DB access # +# --------------------------------------------------------------------------- # + +def db_connect(args): + import pymysql + return pymysql.connect( + host=args.db_host, + port=args.db_port, + user=args.db_user, + password=args.db_password, + database=args.db_name, + charset="utf8mb4", + cursorclass=pymysql.cursors.DictCursor, + ) + + +def fetch_custom_taxi_nodes(conn): + with conn.cursor() as c: + c.execute( + "SELECT id, name, map_id, position_x, position_y, position_z, " + " mount_alliance, mount_horde " + "FROM custom_taxi_nodes ORDER BY id" + ) + return list(c.fetchall()) + + +def fetch_custom_taxi_paths(conn): + with conn.cursor() as c: + c.execute( + "SELECT id, from_taxi_node_id, to_taxi_node_id, cost " + "FROM custom_taxi_paths WHERE is_enabled = 1 ORDER BY id" + ) + return list(c.fetchall()) + + +def fetch_custom_taxi_path_nodes(conn): + with conn.cursor() as c: + c.execute( + "SELECT path_id, node_index, map_id, position_x, position_y, position_z, " + " flags, delay, arrival_event_id, departure_event_id " + "FROM custom_taxi_path_nodes ORDER BY path_id, node_index" + ) + return list(c.fetchall()) + + +# --------------------------------------------------------------------------- # +# Export pipeline # +# --------------------------------------------------------------------------- # + +def merge_taxinodes(dbc: DBC, custom_rows: list) -> int: + """Append custom rows to the loaded TaxiNodes DBC. Returns the count + of appended records. Picks the locale_slot and name_lang_mask from + the vanilla file so our records are byte-shaped exactly like the + surrounding Blizzard data — strict clients reject anything else.""" + if dbc.record_size != TAXI_NODES_RECSIZE: + raise ValueError( + f"TaxiNodes.dbc record size {dbc.record_size} != expected {TAXI_NODES_RECSIZE}" + ) + + locale_slot, lang_mask = infer_taxinodes_locale_layout(dbc) + print(f"TaxiNodes locale layout: slot={locale_slot}, lang_mask=0x{lang_mask:08X}") + + vanilla_ids = {struct.unpack_from(" int: + if dbc.record_size != TAXI_PATH_RECSIZE: + raise ValueError( + f"TaxiPath.dbc record size {dbc.record_size} != expected {TAXI_PATH_RECSIZE}" + ) + + vanilla_ids = {struct.unpack_from(" int: + if dbc.record_size != TAXI_PATH_NODE_RECSIZE: + raise ValueError( + f"TaxiPathNode.dbc record size {dbc.record_size} != expected {TAXI_PATH_NODE_RECSIZE}" + ) + + next_id = vanilla_max_id_uint32_at_offset(dbc.records, 0) + 1 + appended = 0 + for row in custom_rows: + dbc.records.append(pack_taxipathnode_record( + record_id=next_id, + path_id=row["path_id"], + node_index=row["node_index"], + map_id=row["map_id"], + x=row["position_x"], + y=row["position_y"], + z=row["position_z"], + flags=row["flags"], + delay=row["delay"], + arrival_event=row["arrival_event_id"], + departure_event=row["departure_event_id"], + )) + next_id += 1 + appended += 1 + sort_records_by_id(dbc) + return appended + + +# --------------------------------------------------------------------------- # +# sha256 helpers # +# --------------------------------------------------------------------------- # + +def sha256_file(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as f: + for chunk in iter(lambda: f.read(65536), b""): + h.update(chunk) + return h.hexdigest() + + +def write_manifest(folder: Path, hashes: dict) -> None: + folder.mkdir(parents=True, exist_ok=True) + lines = [f"{h} {name}" for name, h in sorted(hashes.items())] + (folder / "sha256.txt").write_text("\n".join(lines) + "\n", encoding="utf-8") + + +# --------------------------------------------------------------------------- # +# Commands # +# --------------------------------------------------------------------------- # + +def cmd_export_dbc(args) -> int: + vanilla_dir = Path(args.vanilla_dir).expanduser().resolve() + output_dir = Path(args.output_dir).expanduser().resolve() + + server_dir = output_dir / "server" / "dbc" + client_dir = output_dir / "client" / "DBFilesClient" + + print(f"Vanilla source: {vanilla_dir}") + print(f"Output: {output_dir}") + + # Validate vanilla files exist before touching the DB. + for fname in DBC_FILES: + p = vanilla_dir / fname + if not p.is_file(): + print(f"ERROR: vanilla DBC missing: {p}", file=sys.stderr) + return 2 + + print("Connecting to MySQL...") + conn = db_connect(args) + try: + nodes = fetch_custom_taxi_nodes(conn) + paths = fetch_custom_taxi_paths(conn) + wps = fetch_custom_taxi_path_nodes(conn) + finally: + conn.close() + + print(f"Custom nodes: {len(nodes)}, paths: {len(paths)}, waypoints: {len(wps)}") + + # Sanity checks on the data before we burn DBC bytes. + if paths and not wps: + print("WARNING: paths defined but no waypoints — generated DBC will be useless.", + file=sys.stderr) + enabled_path_ids = {p["id"] for p in paths} + waypoints_by_path = {} + for wp in wps: + waypoints_by_path.setdefault(wp["path_id"], []).append(wp) + for pid in waypoints_by_path: + if pid not in enabled_path_ids: + print(f"WARNING: waypoints reference disabled/unknown path_id={pid}; skipping.", + file=sys.stderr) + wps_filtered = [wp for wp in wps if wp["path_id"] in enabled_path_ids] + + # Read vanilla, merge, write. + nodes_dbc = DBC.read(vanilla_dir / "TaxiNodes.dbc") + paths_dbc = DBC.read(vanilla_dir / "TaxiPath.dbc") + pn_dbc = DBC.read(vanilla_dir / "TaxiPathNode.dbc") + + # Compounding guard: if the source already contains records in the + # taxi-builder custom-id space, the user is pointing --vanilla-dir at + # an already-merged tree. Continuing would duplicate entries. + # NB: TaxiMaskSize=14 caps node ids at 448 — vanilla 3.3.5a uses up to + # ~440, so 441 is the practical floor for custom node ids. Path ids are + # not bound by the mask and live in their own large range. + CUSTOM_NODE_FLOOR = 441 + CUSTOM_PATH_FLOOR = 1979 + seen_nodes = sum( + 1 for r in nodes_dbc.records + if struct.unpack_from("= CUSTOM_NODE_FLOOR + ) + seen_paths = sum( + 1 for r in paths_dbc.records + if struct.unpack_from("= CUSTOM_PATH_FLOOR + ) + if seen_nodes or seen_paths: + print( + f"ERROR: vanilla source at {vanilla_dir} already contains " + f"{seen_nodes} TaxiNodes id>={CUSTOM_NODE_FLOOR} and " + f"{seen_paths} TaxiPath id>={CUSTOM_PATH_FLOOR} — looks already-merged.\n" + f" Point --vanilla-dir at a clean snapshot, or restore " + f"vanilla DBCs into {vanilla_dir} before re-running.", + file=sys.stderr) + return 3 + + n_appended_nodes = merge_taxinodes(nodes_dbc, nodes) + n_appended_paths = merge_taxipath(paths_dbc, paths) + n_appended_pn = merge_taxipathnode(pn_dbc, wps_filtered) + + print(f"Appended: TaxiNodes +{n_appended_nodes}, " + f"TaxiPath +{n_appended_paths}, " + f"TaxiPathNode +{n_appended_pn}") + print(f"Final: TaxiNodes {len(nodes_dbc.records)} records, " + f"TaxiPath {len(paths_dbc.records)} records, " + f"TaxiPathNode {len(pn_dbc.records)} records") + + # Write to server/dbc and client/DBFilesClient. Same bytes both sides. + targets = { + "TaxiNodes.dbc": nodes_dbc, + "TaxiPath.dbc": paths_dbc, + "TaxiPathNode.dbc": pn_dbc, + } + server_hashes = {} + client_hashes = {} + for fname, dbc in targets.items(): + spath = server_dir / fname + cpath = client_dir / fname + dbc.write(spath) + dbc.write(cpath) + server_hashes[fname] = sha256_file(spath) + client_hashes[fname] = sha256_file(cpath) + + write_manifest(server_dir, server_hashes) + write_manifest(client_dir, client_hashes) + + print() + print("sha256 (server):") + for fname, h in sorted(server_hashes.items()): + print(f" {h} {fname}") + print("sha256 (client):") + for fname, h in sorted(client_hashes.items()): + print(f" {h} {fname}") + + if server_hashes != client_hashes: + print("Server/client DBC match: FAIL", file=sys.stderr) + return 1 + print("Server/client DBC match: OK") + return 0 + + +def cmd_build_client_patch(args) -> int: + input_dir = Path(args.input_dir).expanduser().resolve() + output_file = Path(args.output_file).expanduser().resolve() + + print(f"Client DBC input: {input_dir}") + print(f"MPQ output: {output_file}") + + files = {} + listfile_lines = [] + for internal_path, fname in zip(CLIENT_MPQ_PATHS, DBC_FILES): + source = input_dir / fname + if not source.is_file(): + print(f"ERROR: client DBC missing: {source}", file=sys.stderr) + return 2 + files[internal_path] = source.read_bytes() + listfile_lines.append(internal_path) + + files["(listfile)"] = ("\r\n".join(listfile_lines) + "\r\n").encode("ascii") + + build_mpq(files, output_file) + verify_mpq(files, output_file) + + print() + print(f"MPQ sha256: {sha256_file(output_file)} {output_file.name}") + print("Client install:") + print(f" Copy to: \\Data\\{output_file.name}") + print(" Then delete the client Cache directory and restart Wow.exe.") + return 0 + + +def cmd_dump_vanilla(args) -> int: + """Read a vanilla DBC and dump header + first few raw records.""" + p = Path(args.dbc).expanduser().resolve() + dbc = DBC.read(p) + print(f"{p}") + print(f" field_count={dbc.field_count} record_size={dbc.record_size} " + f"records={len(dbc.records)} string_block={len(dbc.string_block)}") + n = min(args.limit, len(dbc.records)) + for i in range(n): + r = dbc.records[i] + ints = struct.unpack(f"<{dbc.record_size // 4}I", r) + print(f" [{i}] {ints}") + return 0 + + +def cmd_thin(args) -> int: + """Drop intermediate waypoints that sit within args.min_yd (XY) of the + previously kept point. Always keeps the first and the last waypoint; + re-numbers node_index from 0 in place. Original rows are saved to a + backup table so the operation is reversible.""" + import time + + if args.min_yd < 1.0: + print("--min-yd must be >= 1", file=sys.stderr) + return 2 + + conn = db_connect(args) + try: + with conn.cursor() as c: + c.execute( + "SELECT id, path_id, node_index, map_id, position_x, position_y, position_z, " + " flags, delay, arrival_event_id, departure_event_id, comment " + "FROM custom_taxi_path_nodes WHERE path_id = %s ORDER BY node_index", + (args.path,), + ) + rows = list(c.fetchall()) + + if not rows: + print(f"path {args.path} has no waypoints", file=sys.stderr) + return 1 + if len(rows) < 3: + print(f"path {args.path} has only {len(rows)} waypoints — nothing to thin") + return 0 + + def dist_xy(a, b): + dx = a["position_x"] - b["position_x"] + dy = a["position_y"] - b["position_y"] + return (dx * dx + dy * dy) ** 0.5 + + kept = [rows[0]] + for wp in rows[1:-1]: + if wp["map_id"] != kept[-1]["map_id"]: + kept.append(wp) + continue + if dist_xy(wp, kept[-1]) >= args.min_yd: + kept.append(wp) + kept.append(rows[-1]) + + # If the second-to-last we kept is now inside the spacing window + # of the final point, drop it — we want the last point unique. + while ( + len(kept) >= 3 + and kept[-2]["map_id"] == kept[-1]["map_id"] + and dist_xy(kept[-2], kept[-1]) < args.min_yd + ): + kept.pop(-2) + + print(f"Path {args.path}: {len(rows)} -> {len(kept)} waypoints " + f"(min_yd={args.min_yd}, removed {len(rows) - len(kept)})") + + if args.dry_run: + print("(dry run — DB not modified). New sequence:") + for i, wp in enumerate(kept): + seg = "" + if i > 0 and kept[i - 1]["map_id"] == wp["map_id"]: + seg = f" seg={dist_xy(kept[i - 1], wp):.1f}yd" + print(f" [{i:>3}] (was {wp['node_index']:>3}) map={wp['map_id']} " + f"({wp['position_x']:.1f}, {wp['position_y']:.1f}, {wp['position_z']:.1f}){seg}") + return 0 + + ts = int(time.time()) + backup_table = f"custom_taxi_path_nodes_backup_{args.path}_{ts}" + with conn.cursor() as c: + c.execute( + f"CREATE TABLE `{backup_table}` AS " + "SELECT * FROM custom_taxi_path_nodes WHERE path_id = %s", + (args.path,), + ) + c.execute( + "DELETE FROM custom_taxi_path_nodes WHERE path_id = %s", + (args.path,), + ) + for new_idx, wp in enumerate(kept): + c.execute( + "INSERT INTO custom_taxi_path_nodes " + "(path_id, node_index, map_id, position_x, position_y, position_z, " + " flags, delay, arrival_event_id, departure_event_id, comment) " + "VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)", + (args.path, new_idx, wp["map_id"], + wp["position_x"], wp["position_y"], wp["position_z"], + wp["flags"], wp["delay"], + wp["arrival_event_id"], wp["departure_event_id"], wp["comment"]), + ) + conn.commit() + + print(f"Backup table: `{backup_table}`") + print("To rollback:") + print(f" DELETE FROM custom_taxi_path_nodes WHERE path_id = {args.path};") + print(f" INSERT INTO custom_taxi_path_nodes " + f"SELECT * FROM `{backup_table}`;") + finally: + conn.close() + return 0 + + +def cmd_not_implemented(args) -> int: + print(f"`{args.subcommand}` is not implemented in the MVP.", file=sys.stderr) + return 2 + + +# --------------------------------------------------------------------------- # +# CLI wiring # +# --------------------------------------------------------------------------- # + +def build_parser() -> argparse.ArgumentParser: + repo_root = Path(__file__).resolve().parent.parent.parent + default_vanilla = repo_root / "var" / "client-data-vanilla" + default_output = repo_root / "build" / "taxi" / "export" + default_patch = default_output / "client" / "patch-Z.MPQ" + + p = argparse.ArgumentParser(prog="moonwell-taxi", description=__doc__) + sub = p.add_subparsers(dest="subcommand", required=True) + + # Shared DB connection flags. + def add_db_flags(sp): + sp.add_argument("--db-host", default=os.environ.get("MOONWELL_TAXI_DB_HOST", "127.0.0.1")) + sp.add_argument("--db-port", type=int, + default=int(os.environ.get("DOCKER_DB_EXTERNAL_PORT", "3306"))) + sp.add_argument("--db-user", default=os.environ.get("MOONWELL_TAXI_DB_USER", "root")) + sp.add_argument("--db-password", default=os.environ.get("DOCKER_DB_ROOT_PASSWORD", "password")) + sp.add_argument("--db-name", default=os.environ.get("MOONWELL_TAXI_DB_NAME", "acore_world")) + + sp_export = sub.add_parser("export-dbc", help="merge custom + vanilla into new DBCs") + sp_export.add_argument("--vanilla-dir", default=str(default_vanilla), + help="directory containing TaxiNodes.dbc / TaxiPath.dbc / TaxiPathNode.dbc " + "(default: repo root)") + sp_export.add_argument("--output-dir", default=str(default_output), + help="output base; server/ and client/ subtrees are created here") + add_db_flags(sp_export) + sp_export.set_defaults(func=cmd_export_dbc) + + sp_patch = sub.add_parser("build-client-patch", help="pack exported client DBCs into a WoW MPQ patch") + sp_patch.add_argument("--input-dir", default=str(default_output / "client" / "DBFilesClient"), + help="directory containing exported client Taxi*.dbc files") + sp_patch.add_argument("--output-file", default=str(default_patch), + help="MPQ file to write") + sp_patch.set_defaults(func=cmd_build_client_patch) + + sp_dump = sub.add_parser("dump-vanilla", help="dump header + records for a DBC") + sp_dump.add_argument("dbc", help="path to a .dbc file") + sp_dump.add_argument("--limit", type=int, default=5) + sp_dump.set_defaults(func=cmd_dump_vanilla) + + sp_thin = sub.add_parser("thin", help="drop too-close waypoints in a path") + sp_thin.add_argument("--path", type=int, required=True, help="custom_taxi_paths.id to thin") + sp_thin.add_argument("--min-yd", type=float, default=50.0, + help="minimum XY segment distance to keep (default 50)") + sp_thin.add_argument("--dry-run", action="store_true", + help="show what would change, do not touch the DB") + add_db_flags(sp_thin) + sp_thin.set_defaults(func=cmd_thin) + + for stub in ("build-mpq", "deploy-server", "backup", "rollback", "diff"): + sp = sub.add_parser(stub, help=f"(not implemented in MVP) {stub}") + sp.set_defaults(func=cmd_not_implemented) + + return p + + +def main(argv=None) -> int: + repo_root = Path(__file__).resolve().parent.parent.parent + load_env_file(repo_root / ".env") + + parser = build_parser() + args = parser.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/moonwell-taxi/redeploy.sh b/tools/moonwell-taxi/redeploy.sh new file mode 100644 index 0000000..5cdc5ec --- /dev/null +++ b/tools/moonwell-taxi/redeploy.sh @@ -0,0 +1,53 @@ +#!/usr/bin/env bash +# Re-export TaxiNodes / TaxiPath / TaxiPathNode DBCs from custom_taxi_* +# tables, copy them into the worldserver's bind-mounted data directory, +# and restart the worldserver. Idempotent. +# +# Vanilla baseline lives in var/client-data-vanilla/ and is treated as +# read-only: the export tool reads from there but writes to +# build/taxi/export/. Never point --vanilla-dir at the live tree, or +# subsequent exports will compound their own custom rows. + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +VANILLA_DIR="${VANILLA_DIR:-$ROOT_DIR/var/client-data-vanilla}" +LIVE_DIR="${LIVE_DIR:-$ROOT_DIR/var/client-data-live/dbc}" +EXPORT_DIR="${EXPORT_DIR:-$ROOT_DIR/build/taxi/export}" +SERVER_OUT="$EXPORT_DIR/server/dbc" + +log() { printf '[redeploy] %s\n' "$*"; } + +if [[ ! -d "$VANILLA_DIR" ]]; then + printf 'vanilla snapshot not found: %s\n' "$VANILLA_DIR" >&2 + printf 'Create it once from a clean live tree:\n' >&2 + printf ' mkdir -p %s && cp %s/Taxi*.dbc %s/\n' \ + "$VANILLA_DIR" "$LIVE_DIR" "$VANILLA_DIR" >&2 + exit 1 +fi + +if [[ ! -d "$LIVE_DIR" ]]; then + printf 'live dbc dir not found: %s\n' "$LIVE_DIR" >&2 + exit 1 +fi + +cd "$ROOT_DIR" + +log "exporting DBCs from $VANILLA_DIR" +python3 "$ROOT_DIR/tools/moonwell-taxi/moonwell_taxi.py" export-dbc \ + --vanilla-dir "$VANILLA_DIR" \ + --output-dir "$EXPORT_DIR" + +log "copying server DBCs into $LIVE_DIR" +cp "$SERVER_OUT/TaxiNodes.dbc" "$LIVE_DIR/" +cp "$SERVER_OUT/TaxiPath.dbc" "$LIVE_DIR/" +cp "$SERVER_OUT/TaxiPathNode.dbc" "$LIVE_DIR/" + +if docker compose ps --status running --services 2>/dev/null | grep -qx 'ac-worldserver'; then + log "restarting ac-worldserver" + docker compose restart ac-worldserver +else + log "ac-worldserver is not running; new DBCs will be picked up on next start" +fi + +log "done" diff --git a/tools/moonwell-taxi/requirements.txt b/tools/moonwell-taxi/requirements.txt new file mode 100644 index 0000000..6317d3c --- /dev/null +++ b/tools/moonwell-taxi/requirements.txt @@ -0,0 +1 @@ +PyMySQL>=1.1